diff --git a/.fmf/version b/.fmf/version new file mode 100644 index 000000000..d00491fd7 --- /dev/null +++ b/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b9b5ce55b..20f0c6ef8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ # https://help.github.com/en/articles/about-code-owners # Default reviewers for everything -* @ondrejmular @tomjelinek +* @tomjelinek diff --git a/.gitignore b/.gitignore index b08138ea8..7ea8d7772 100644 --- a/.gitignore +++ b/.gitignore @@ -15,11 +15,12 @@ pcs/snmp/pcs_snmp_agent.service /pcsd/public/ui /Gemfile* /scripts/pcsd.sh +pcs.pc pcs-* .mypy_cache/ requirements.txt -setup.py -setup.cfg +/setup.py +/setup.cfg pcs/api_v2_client pcs_test/api_v2_client pcs/pcs @@ -32,6 +33,7 @@ pcs_test/smoke.sh pcs_test/tools/bin_mock/pcmk/crm_resource pcs_test/tools/bin_mock/pcmk/pacemaker_metadata pcs_test/tools/bin_mock/pcmk/pacemaker-fenced +pcs_test/tools/bin_mock/pcmk/stonith_admin pcs_test/resources/*.tmp pcs_test/resources/temp*.xml pcs_test/resources/temp* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b63e03574..15ec34c32 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,148 +1,149 @@ -default: - before_script: - - git remote add upstream https://github.com/ClusterLabs/pcs.git - - git fetch upstream +--- +# Keep the previos behavior when jobs are not run in merge request pipelines. +# https://docs.gitlab.com/ci/jobs/job_rules/#avoid-duplicate-pipelines +# https://docs.gitlab.com/ci/yaml/workflow/ +# https://docs.gitlab.com/ci/pipelines/merge_request_pipelines/ +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + when: never + - when: always +variables: + # override these by CI variables if needed + ENABLE_FIPS: + description: Enable FIPS mode in testing farm. Set to empty to disable FIPS + value: --insert --order 0 --how feature --fips enabled + options: + - --insert --order 0 --how feature --fips enabled + - "" + PLAN_FILTER: + description: Predefined plan filters for testing farm. Default is empty + which means run all plans. + value: "" + options: + - "" + - "name:.*linters" + - "name:.*tier0" + - "name:.*tier1" + - 'name:.*linters\|.*tier0' + TEST_FILTER: + description: Predefined test filters for testing farm. Default is empty + which means run all test. + value: "" + options: + - "" + - 'name:(?!.*python_tier1_tests).*' # skip python_tier1_tests + - "tag:distcheck" + - "tag:lint" + - "tag:python" + - "tag:rpm_build" + - "tag:ruby" + - "tag:smoke" + - "tag:tier0" + - "tag:tier1" + TFCLI_PLAN_FILTER: + description: Set to $PLAN_FILTER to use a predefined plan filter or specify + a custom one. + value: $PLAN_FILTER + TFCLI_TEST_FILTER: + description: Set to $TEST_FILTER to use a predefined test filter or specify + a custom one. + value: $TEST_FILTER + TFCLI_TMT_PREPARE: $ENABLE_FIPS + TF_REQUEST_ID_FILE: /tmp/tf_request_id-$CI_JOB_ID + TF_EXIT_CODE_FILE: /tmp/tf_exit_code-$CI_JOB_ID + + +# https://docs.gitlab.com/ci/yaml/#parallelmatrix +# COPMOSE_NAME +# * values must use only letters and numbers because they are used as part +# of rpm name +# * must be kept short because they are used in job names +# * other pipelines use a job name to download the job's artifacts +# TF_COMPOSE +# * name of a testing farm compose +# * list of composes: +# https://api.testing-farm.io/v0.1/composes/public +# https://api.testing-farm.io/v0.1/composes/redhat +# TMT_DISTRO +# * value for the tmt context variable distro: +# https://tmt.readthedocs.io/en/stable/spec/context.html +# https://fmf.readthedocs.io/en/latest/context.html +# .parallel: parallel: matrix: - - BASE_IMAGE_NAME: ["PcsRhel9CurrentRelease", "PcsRhel9Next", "PcsFedoraCurrentRelease"] + - COMPOSE_NAME: + - Rhel9Next + - Rhel9CurrentRelease + rules: + - if: $COMPOSE_NAME == "Rhel9Next" + variables: + TF_COMPOSE: RHEL-9.9.0-Nightly + TMT_DISTRO: rhel-9.9 + - if: $COMPOSE_NAME == "Rhel9CurrentRelease" + variables: + TF_COMPOSE: RHEL-9.8.0-Nightly + TMT_DISTRO: rhel-9.8 -stages: - - stage1 - - stage2 +.download_artifacts_from_tf: &download_artifacts_from_tf + - UUID_REGEX="[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}" + - REQUEST_ID=$(sed -n -E "s/.*($UUID_REGEX).*/\1/p" output.txt | head -n 1) + - echo "$REQUEST_ID" > "$TF_REQUEST_ID_FILE" + - echo "Testing Farm request id is $REQUEST_ID" + - EXIT_CODE=0 + - testing-farm watch --id "$REQUEST_ID" || EXIT_CODE=$? + - echo $EXIT_CODE > "$TF_EXIT_CODE_FILE" + - > + curl --insecure --location --remote-name \ + "https://artifacts.osci.redhat.com/testing-farm/$REQUEST_ID/results.xml" + - > + for ARTIFACT_DIR in rpms dist var/log/pcsd; do + for ARTIFACT_URL in $( + xmllint \ + --xpath "//log[starts-with(@name, 'data/$ARTIFACT_DIR')]/@href" \ + results.xml \ + | sed "s/.*=\"\(.*\)\"/\1/" + ); do + echo $ARTIFACT_URL >&2; + curl --insecure --remote-name --create-dirs \ + --output-dir "$ARTIFACT_DIR" "$ARTIFACT_URL"; + done + done + - exit $EXIT_CODE -rpm_build: +tf_tests: extends: .parallel - stage: stage1 + image: $SHARED_RUNNER_CONTAINER_IMAGE script: - - ./autogen.sh - - ./configure --enable-local-build - - make CI_BRANCH=${BASE_IMAGE_NAME} rpm/pcs.spec - - dnf builddep -y rpm/pcs.spec - - make CI_BRANCH=${BASE_IMAGE_NAME} rpm - - mkdir -p rpms && cp -v $(find rpm -type f -name '*.rpm' -not -name '*.src.rpm') rpms + - testing-farm request + --git-url + https://gitlab-ci-token:$CI_JOB_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH + --git-ref $CI_COMMIT_REF_NAME + --compose $TF_COMPOSE + --context distro=$TMT_DISTRO + --environment COMPOSE_NAME=$COMPOSE_NAME + --plan-filter "$TFCLI_PLAN_FILTER" + --test-filter "$TFCLI_TEST_FILTER" + --tmt-prepare "$TFCLI_TMT_PREPARE" + --no-wait + | tee output.txt + - *download_artifacts_from_tf + after_script: + - REQUEST_ID=$(<"$TF_REQUEST_ID_FILE") + - EXIT_CODE=$(<"$TF_EXIT_CODE_FILE") + - echo "CI_JOB_STATUS=$CI_JOB_STATUS EXIT_CODE=$EXIT_CODE" + - > + if [[ "$CI_JOB_STATUS" != "success" && "$EXIT_CODE" != "1" ]]; then \ + echo "Canceling testing farm request $REQUEST_ID"; \ + testing-farm cancel "$REQUEST_ID"; \ + fi + tags: + - $SHARED_RUNNER_TAG artifacts: expire_in: 1 week paths: - rpms - -distcheck: - extends: .parallel - stage: stage1 - script: - - "pip3 install - dacite - tornado - pyagentx - " - - ./autogen.sh - - ./configure --enable-local-build - - make distcheck DISTCHECK_CONFIGURE_FLAGS='--enable-local-build' - - rename --verbose .tar. ".${BASE_IMAGE_NAME}.tar." pcs*.tar.* - - mkdir -p dist && cp -v pcs*.tar.* dist/ - artifacts: - expire_in: 1 week - paths: - dist - -typos: - extends: .parallel - stage: stage1 - script: - - ./autogen.sh - - ./configure --enable-local-build --enable-typos-check - - make - - make typos_check - -black: - extends: .parallel - stage: stage1 - script: - - python3 -m pip install --upgrade -r dev_requirements.txt - - ./autogen.sh - - ./configure --enable-local-build --enable-dev-tests --enable-tests-only - - make black_check - -isort: - extends: .parallel - stage: stage1 - script: - - python3 -m pip install --upgrade -r dev_requirements.txt - - ./autogen.sh - - ./configure --enable-local-build --enable-dev-tests --enable-tests-only - - make isort_check - -pylint: - extends: .parallel - stage: stage1 - script: - - python3 -m pip install --upgrade -r dev_requirements.txt - - ./autogen.sh - - ./configure --enable-local-build --enable-dev-tests --enable-parallel-pylint - - make - - make pylint - -mypy: - extends: .parallel - stage: stage1 - script: - - python3 -m pip install --upgrade -r dev_requirements.txt - - ./autogen.sh - - ./configure --enable-local-build --enable-dev-tests - - make - - make mypy - -ruby_tests: - extends: .parallel - stage: stage1 - script: - - ./autogen.sh - - ./configure --enable-local-build - - make - - make pcsd-tests - -python_tier0_tests: - extends: .parallel - stage: stage1 - script: - # make sure that tier0 tests run without cluster packages installed - - dnf remove -y corosync* pacemaker* fence-agents* resource-agents* booth* sbd - - python3 -m pip install concurrencytest - - ./autogen.sh - - ./configure --enable-local-build - - make - - make tests_tier0 - -python_tier1_tests: - extends: .parallel - stage: stage2 - needs: - - rpm_build - script: - - "dnf install -y rpms/pcs-*${BASE_IMAGE_NAME}*$(rpm -E %{dist}).*.rpm" - - python3 -m pip install concurrencytest - - ./autogen.sh - - ./configure --enable-local-build --enable-destructive-tests --enable-tests-only - - rm -rf pcs pcsd pcs_bundled # make sure we are testing installed package - - pcs_test/suite -v --installed --tier1 - -python_smoke_tests: - extends: .parallel - stage: stage2 - needs: - - rpm_build - script: - - "dnf install -y rpms/pcs-*${BASE_IMAGE_NAME}*$(rpm -E %{dist}).*.rpm" - - systemctl start pcsd - - sleep 2 - - ./autogen.sh - - ./configure --enable-local-build - - make - - rm -rf pcs - - pcs_test/smoke.sh - artifacts: - paths: - - /var/log/pcsd/ - when: on_failure - expire_in: 1 week + - var/log/pcsd diff --git a/CHANGELOG.md b/CHANGELOG.md index a198d0f7e..7239ee33a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,161 @@ # Change Log ## [Unreleased] +### Added +- Commands `pcs cluster node rename-corosync` and `pcs cluster node rename-cib` + for cluster node renaming ([RHEL-149172]) +- `pcs constraint config` (and its variants for each constraint type) now list + resources in sets in the order defined in the CIB, instead of sorting them + alphabetically ([rhbz#2461143]) ([RHEL-176478]) + + +[RHEL-149172]: https://redhat.atlassian.net/browse/RHEL-149172 +[RHEL-176478]: https://redhat.atlassian.net/browse/RHEL-176478 +[rhbz#2461143]: https://bugzilla.redhat.com/show_bug.cgi?id=2461143 + +## [0.11.11] - 2026-01-06 + +### Changed +- Do not wrap resource and stonith agent description to preserve existing + formatting ([RHEL-113763]) + +### Fixed +- Prevent an unhandled exception when accessing pcsd `/remote/...` urls on nodes + with an invalid local corosync configuration + +### Deprecated +- Value `sctp` of the knet link option `transport` is deprecated in + corosync / knet and might be removed in a future release ([RHEL-126842]) + +### Security +- Stop accepting multipart requests in pcsd as we don't use them. This should + boost security as there have been multiple reported vulnerabilities in Rack + and Tornado. Since we use Tornado as a proxy for Rack, this also prevents + future attacks on the Ruby daemon. + +[RHEL-113763]: https://issues.redhat.com/browse/RHEL-113763 +[RHEL-126842]: https://issues.redhat.com/browse/RHEL-126842 + + +## [0.11.10] - 2025-07-09 + +### Added +- Commands `pcs cluster cib-push` and `pcs cluster edit` now print more info + when new CIB does not conform to the CIB schema ([RHEL-76059]) +- Commands `pcs cluster cib-push` and `pcs cluster edit` now print info about + problems in pushed CIB even if it conforms to the CIB schema ([RHEL-76060]) +- Command `pcs stonith sbd watchdog list` now prints watchdogs' identity and + driver ([RHEL-76177]) +- Command `pcs cluster rename` for changing cluster name ([RHEL-76055]) +- Prevent removing or disabling stonith devices or disabling SBD if the cluster + would be left with disabled SBD and no stonith devices ([RHEL-76170]) +- Support for exporting alerts in `json` and `cmd` formats ([RHEL-76153]) +- Output of `pcs status` now contains messages about CIB misconfiguration + provided by `crm_verify` pacemaker tool ([RHEL-76060]) +- Support for bundle resources in `pcs resource meta`, disallow updating + `remote-node` and `remote-addr` without `--force`, add lib command + `resource.update_meta` to API v2 ([RHEL-35420]) +- Support for exporting node attributes and utilization in `json` and `cmd` + formats ([RHEL-76154]) +- Support for reading sate and logs directories from systemd environment + variables `STATE_DIRECTORY` and `LOGS_DIRECTORY` ([RHEL-97220]) + +### Fixed +- Fixed a traceback when removing a resource fails in web UI +- It is now possible to override errors when editing cluster properties in web + UI +- Display node-attribute in colocation constraints configuration ([RHEL-82894]) +- Fixed cluster status parsing when the `target-role` meta attribute is not + properly capitalized as defined by pacemaker specification. Affected + commands: + - `pcs resource|stonith delete|remove`, `pcs booth delete|remove`, + and `pcs cluster node delete-remote|remove-remote` (broken since 0.11.9) + ([RHEL-92044]) + - `pcs status query resource` (broken since 0.11.8) +- Handle query limit errors coming from rubygem-rack ([RHEL-90151]) + +[RHEL-35420]: https://issues.redhat.com/browse/RHEL-35420 +[RHEL-76055]: https://issues.redhat.com/browse/RHEL-76055 +[RHEL-76059]: https://issues.redhat.com/browse/RHEL-76059 +[RHEL-76060]: https://issues.redhat.com/browse/RHEL-76060 +[RHEL-76153]: https://issues.redhat.com/browse/RHEL-76153 +[RHEL-76154]: https://issues.redhat.com/browse/RHEL-76154 +[RHEL-76170]: https://issues.redhat.com/browse/RHEL-76170 +[RHEL-76177]: https://issues.redhat.com/browse/RHEL-76177 +[RHEL-82894]: https://issues.redhat.com/browse/RHEL-82894 +[RHEL-90151]: https://issues.redhat.com/browse/RHEL-90151 +[RHEL-92044]: https://issues.redhat.com/browse/RHEL-92044 +[RHEL-97220]: https://issues.redhat.com/browse/RHEL-97220 + + +## [0.11.9.1] - 2025-04-14 + +### Fixed +- Command `pcs resource restart` allows restarting bundle instances (broken + since pcs-0.11.9) ([RHEL-79055]) +- Do not end with traceback when using `pcs resource delete` to remove bundle + resources when the bundle has no IP address specified ([RHEL-79160]) + +[RHEL-79055]: https://issues.redhat.com/browse/RHEL-79055 +[RHEL-79160]: https://issues.redhat.com/browse/RHEL-79160 + + +## [0.11.9] - 2025-01-10 + +### Added +- Support for output formats `json` and `cmd` to `pcs tag config` command +([RHEL-46284]) +- Command `resource.restart` in API v2 +- Add lib commands `cluster.get_corosync_conf_struct` and + `resource.get_configured_resources` to API v2 +- Add lib command `status.full_cluster_status_plaintext` to API v1 + ([RHEL-61738]) +- Lib command `cib.remove_elements` can now remove resources +- Support for exporting stonith levels in `json` and `cmd` formats in commands + `pcs stonith config` and `pcs stonith level config` commands ([RHEL-16232]) +- Pkg-config with info for webui is now provided. +- Commands `pcs booth ticket standby`, `pcs booth ticket unstandby`, and + `pcs booth ticket cleanup` which allow for managing the state of the ticket + ([RHEL-69040]) + +### Changed +- Commands `pcs resource delete | remove` and `pcs stonith delete | remove` + now allow ([RHEL-61901]): + - deletion of multiple resources or stonith resources with one command + - deletion of resources or stonith resources included in tags + +### Fixed +- Do not end with error when using the instances quantifier in `pcs status + query resource is-state` command ([RHEL-55441]) +- Do not display a warning in `pcs status` when a fence\_sbd stonith device has + its `method` option set to `cycle` ([RHEL-46286]) +- Do not display expired constraints in `pcs constraint location config + resources` unless `--all` is specified ([RHEL-46293]) +- Displaying status of local and remote cluster sites in `pcs dr status` + command. ([RHEL-61738]) +- Specify the meaning of zero value timeout in `pcs status wait` ([RHEL-46303]) + +### Deprecated +- Using `pcs resource delete | remove` to delete resources representing remote + and guest nodes. Use `pcs cluster node remove-remote` and `pcs cluster node + remove-guest` instead. +- Commands `pcs constraint rule add | delete | remove`, as support for multiple + rules in a location constraint is among the [features planned to be removed + in pacemaker 3] + +[RHEL-16232]: https://issues.redhat.com/browse/RHEL-16232 +[RHEL-46284]: https://issues.redhat.com/browse/RHEL-46284 +[RHEL-46286]: https://issues.redhat.com/browse/RHEL-46286 +[RHEL-46293]: https://issues.redhat.com/browse/RHEL-46293 +[RHEL-46303]: https://issues.redhat.com/browse/RHEL-46303 +[RHEL-55441]: https://issues.redhat.com/browse/RHEL-55441 +[RHEL-61738]: https://issues.redhat.com/browse/RHEL-61738 +[RHEL-61901]: https://issues.redhat.com/browse/RHEL-61901 +[RHEL-69040]: https://issues.redhat.com/browse/RHEL-69040 +[features planned to be removed in pacemaker 3]: https://projects.clusterlabs.org/w/projects/pacemaker/pacemaker_3.0_changes/pacemaker_3.0_configuration_changes/ + + +## [0.11.8] - 2024-07-09 ### Added - Support for output formats `json` and `cmd` to resources/stonith defaults and @@ -21,9 +176,15 @@ ([RHEL-27492]) - Use different process creation method for multiprocessing module in order to avoid deadlock on process termination. ([ghissue#780], [RHEL-28749]) +- Do not wrap pcs output to terminal width if pcs's stdout is redirected + ([RHEL-36514]) +- Report an error when an invalid resource-discovery is specified ([RHEL-7701]) +- 'pcs booth destroy' now works for nodes without a cluster (such as + arbitrators) ([RHEL-7737]) +- Validate SBD\_DELAY\_START and SBD\_STARTMODE options ([RHEL-17962]) ### Deprecated -- Pcs produces warnings about [features planned to be removed in pacemaker 3](https://projects.clusterlabs.org/w/projects/pacemaker/pacemaker_3.0_changes/pacemaker_3.0_configuration_changes/): +- Pcs produces warnings about [features planned to be removed in pacemaker 3]: - score in order constraints - using rkt in bundles - upstart and nagios resources @@ -32,11 +193,16 @@ [ghissue#772]: https://github.com/ClusterLabs/pcs/issues/772 [ghissue#780]: https://github.com/ClusterLabs/pcs/issues/780 [RHEL-2977]: https://issues.redhat.com/browse/RHEL-2977 +[RHEL-7701]: https://issues.redhat.com/browse/RHEL-7701 +[RHEL-7737]: https://issues.redhat.com/browse/RHEL-7737 [RHEL-16231]: https://issues.redhat.com/browse/RHEL-16231 +[RHEL-17962]: https://issues.redhat.com/browse/RHEL-17962 +[RHEL-21051]: https://issues.redhat.com/browse/RHEL-21051 +[RHEL-25854]: https://issues.redhat.com/browse/RHEL-25854 [RHEL-27492]: https://issues.redhat.com/browse/RHEL-27492 [RHEL-28749]: https://issues.redhat.com/browse/RHEL-28749 -[RHEL-25854]: https://issues.redhat.com/browse/RHEL-25854 -[RHEL-21051]: https://issues.redhat.com/browse/RHEL-21051 +[RHEL-36514]: https://issues.redhat.com/browse/RHEL-36514 +[features planned to be removed in pacemaker 3]: https://projects.clusterlabs.org/w/projects/pacemaker/pacemaker_3.0_changes/pacemaker_3.0_configuration_changes/ ## [0.11.7] - 2024-01-11 @@ -654,7 +820,7 @@ ### Added - New section in pcs man page summarizing changes in pcs-0.10. Commands removed - or changed in pcs-0.10 print errors poiting to that section. ([rhbz#1728890]) + or changed in pcs-0.10 print errors pointing to that section. ([rhbz#1728890]) - `pcs resource disable` can show effects of disabling resources and prevent disabling resources if any other resources would be affected ([rhbz#1631519]) - `pcs resource relations` command shows relations between resources such as @@ -749,7 +915,7 @@ - Removed command `pcs resource show` dropped from usage and man page ([rhbz#1656953]) - Put proper link options' names to corosync.conf ([rhbz#1659051]) -- Fixed issuses in configuring links in the 'create cluster' form in web UI +- Fixed issues in configuring links in the 'create cluster' form in web UI ([rhbz#1664057]) - Pcs no longer removes empty `meta_attributes`, `instance_attributes` and other nvsets and similar elements from CIB. Such behavior was causing problems when diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e1570a70..8ebbb5b14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,9 @@ # Contributing to the pcs project +## Applicable policies +Pull request must comply with the following policies: +* [AI policy](https://github.com/ClusterLabs/pcs/wiki/Policy-on-Use-of-Artificial-Intelligence-in-Pull-Requests) + ## Running pcs and its test suite ### Python virtual environment @@ -41,10 +45,11 @@ ### Pcs test suite * To run all the tests, type `make check`. * You may run specific tests like this: - * `make black_check` - * `make isort_check` + * `make ruff_format_check` + * `make ruff_isort_check` * `make mypy` - * `make pylint` + * `make ruff_lint` + * `make typos_check` * `make tests_tier0` * `make tests_tier1` * `make pcsd-tests` @@ -58,8 +63,8 @@ `make distcheck DISTCHECK_CONFIGURE_FLAGS='...'`. * The point of this test is to make sure all necessary files are present in the tarball. -* To run black code formatter, type `make black`. -* To run isort code formatter, type `make isort`. +* To run ruff isort code formatter, type `make ruff_isort`. +* To run ruff code formatter, type `make ruff_format`. ### Distribution tarball * To create a tarball for distribution, run `make dist`. diff --git a/Makefile.am b/Makefile.am index f1a714e22..cc8f5c0aa 100644 --- a/Makefile.am +++ b/Makefile.am @@ -9,7 +9,7 @@ EXTRA_DIST = \ make/release.mk \ MANIFEST.in \ mypy.ini \ - pylintrc \ + pcs.pc.in \ pyproject.toml \ rpm/pcs.spec.in \ scripts/pcsd.sh.in \ @@ -129,7 +129,8 @@ if ENABLE_DOWNLOAD echo 'BUNDLE_TIMEOUT: 30' >> .bundle/config echo 'BUNDLE_RETRY: 30' >> .bundle/config echo 'BUNDLE_JOBS: 1' >> .bundle/config - $(BUNDLE) + echo 'BUNDLE_FORCE_RUBY_PLATFORM: "true"' >> .bundle/config + $(BUNDLE) --redownload cp -rp $(PCSD_BUNDLED_DIR_LOCAL)/* $(PCSD_BUNDLED_DIR_ROOT_LOCAL)/ rm -rf $$(realpath $(PCSD_BUNDLED_DIR_LOCAL)/../) rm -rf .bundle Gemfile.lock @@ -179,6 +180,9 @@ uninstall-local: dist_doc_DATA = README.md CHANGELOG.md +pkgconfigdir = $(LIB_DIR)/pkgconfig +pkgconfig_DATA = pcs.pc + # testing if CONCISE_TESTS @@ -187,39 +191,30 @@ else python_test_options = -v --vanilla endif -pylint: +ruff_format_check: pyproject.toml if DEV_TESTS -if PARALLEL_PYLINT -pylint_options = --jobs=0 -else -pylint_options = -endif - export PYTHONPATH=${abs_top_builddir}/${PCS_BUNDLED_DIR_LOCAL}/packages && \ - $(TIME) $(PYTHON) -m pylint --rcfile pylintrc --persistent=n --reports=n --score=n --disable similarities ${pylint_options} ${PCS_PYTHON_PACKAGES} + $(TIME) ruff --config pyproject.toml format --check ${PCS_PYTHON_PACKAGES} endif -isort_check: pyproject.toml +ruff_format: pyproject.toml if DEV_TESTS - export PYTHONPATH=${abs_top_builddir}/${PCS_BUNDLED_DIR_LOCAL}/packages && \ - $(TIME) $(PYTHON) -m isort --check-only ${PCS_PYTHON_PACKAGES} + $(TIME) ruff --config pyproject.toml check --select I --fix ${PCS_PYTHON_PACKAGES} + $(TIME) ruff --config pyproject.toml format ${PCS_PYTHON_PACKAGES} endif -isort: pyproject.toml +ruff_isort_check: pyproject.toml if DEV_TESTS - export PYTHONPATH=${abs_top_builddir}/${PCS_BUNDLED_DIR_LOCAL}/packages && \ - $(TIME) $(PYTHON) -m isort ${PCS_PYTHON_PACKAGES} + $(TIME) ruff --config pyproject.toml check --select I ${PCS_PYTHON_PACKAGES} endif -black_check: pyproject.toml +ruff_isort: pyproject.toml if DEV_TESTS - export PYTHONPATH=${abs_top_builddir}/${PCS_BUNDLED_DIR_LOCAL}/packages && \ - $(TIME) $(PYTHON) -m black --config pyproject.toml --check ${PCS_PYTHON_PACKAGES} + $(TIME) ruff --config pyproject.toml check --select I --fix ${PCS_PYTHON_PACKAGES} endif -black: pyproject.toml +ruff_lint: pyproject.toml if DEV_TESTS - export PYTHONPATH=${abs_top_builddir}/${PCS_BUNDLED_DIR_LOCAL}/packages && \ - $(PYTHON) -m black --config pyproject.toml ${PCS_PYTHON_PACKAGES} + $(TIME) ruff --config pyproject.toml check ${PCS_PYTHON_PACKAGES} endif mypy: @@ -228,17 +223,17 @@ if DEV_TESTS $(TIME) $(PYTHON) -m mypy --config-file mypy.ini --package pcs --package pcs_test endif -RUN_TYPOS=$(TYPOS) --color never --format brief | sed -e 's/:[0-9]\+:[0-9]\+:/:/' | $(SORT) +RUN_TYPOS=typos --color never --format brief | sed -e 's/:[0-9]\+:[0-9]\+:/:/' | $(SORT) .PHONY: typos_check typos_check: -if TYPOS_CHECK +if DEV_TESTS $(RUN_TYPOS) > typos_new $(DIFF) typos_known typos_new endif .PHONY: typos_known typos_known: -if TYPOS_CHECK +if DEV_TESTS $(RUN_TYPOS) > typos_known endif @@ -320,7 +315,7 @@ test-tree-clean: fi find ${abs_top_builddir} -type d -name __pycache__ -exec rm -rf {} \; 2>/dev/null || : -check-local: check-local-deps test-tree-prep typos_check pylint isort_check black_check mypy tests_tier0 tests_tier1 pcsd-tests test-tree-clean +check-local: check-local-deps test-tree-prep typos_check ruff_lint ruff_isort_check ruff_format_check mypy tests_tier0 tests_tier1 pcsd-tests test-tree-clean clean-local: test-tree-clean $(PYTHON) setup.py clean @@ -385,8 +380,8 @@ $(SPEC): $(SPEC).in .version config.status stamps/download_python_deps stamps/do if [ "$$numcomm" = "0" ]; then \ sed \ -e "s#@version@#$$rpmver#g" \ - -e "s#%glo.*alpha.*##g" \ - -e "s#%glo.*numcomm.*##g" \ + -e "s#%global\s\+alphatag.*##g" \ + -e "s#%global\s\+numcomm.*##g" \ -e "s#@dirty@#$$dirty#g" \ -e "s#@date@#$$date#g" \ -e "s#@pcs_bundled_dir@#${PCS_BUNDLED_DIR_LOCAL}#g" \ diff --git a/README.md b/README.md index 181c3c8ac..bada0707b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ -## PCS - Pacemaker/Corosync Configuration System +# PCS - Pacemaker/Corosync Configuration System Pcs is a Corosync and Pacemaker configuration tool. It permits users to easily view, modify and create Pacemaker based clusters. Pcs contains pcsd, a pcs daemon, which operates as a remote server for pcs. ---- - -### Pcs Branches +## Pcs Branches * main * This is where pcs-0.12 lives. @@ -26,9 +24,7 @@ daemon, which operates as a remote server for pcs. CMAN are supported. * This branch is no longer maintained. ---- - -### Dependencies +## Dependencies These are the runtime dependencies of pcs and pcsd: * python 3.9+ @@ -45,10 +41,9 @@ These are the runtime dependencies of pcs and pcsd: * killall (package psmisc) * corosync 3.x * pacemaker 2.1 +* certutil (package nss-tools or mozilla-nss-tools) ---- - -### Installation from Source +## Installation from Source Apart from the dependencies listed above, these are also required for installation: @@ -69,16 +64,24 @@ installation: During the installation, all required rubygems are automatically downloaded and compiled. +Web UI frontend is no longer part of pcs sources. You can get it at +[https://github.com/ClusterLabs/pcs-web-ui](https://github.com/ClusterLabs/pcs-web-ui). + To install pcs and pcsd run the following in terminal: ```shell ./autogen.sh -./configure -# alternatively './configure --enable-local-build' can be used to also download -# missing dependencies +./configure [--enable-local-build] [--enable-individual-bundling] make make install ``` +**Common configure options**: +* `--enable-local-build` - downloads all Python dependencies which can be + bundled - see macros `PCS_CHECK_PYMOD` with 3rd argument `[yes]` in + `configure.ac` +* `--enable-individual-bundling` - downloads only dependencies (both Python and + rubygems) which are not installed on the system + If you are using GNU/Linux with systemd, it is now time to: ```shell systemctl daemon-reload @@ -90,9 +93,7 @@ systemctl start pcsd systemctl enable pcsd ``` ---- - -### Packages +## Packages Currently this is built into Fedora, RHEL, CentOS and Debian and its derivates. It is likely that other Linux distributions also contain pcs @@ -101,9 +102,7 @@ packages. * [Current Fedora .spec](https://src.fedoraproject.org/rpms/pcs/blob/rawhide/f/pcs.spec) * [Debian-HA project home page](https://wiki.debian.org/Debian-HA) ---- - -### Quick Start +## Quick Start * **Authenticate cluster nodes** @@ -149,9 +148,7 @@ packages. pcs resource create --help ``` ---- - -### Further Documentation +## Further Documentation [ClusterLabs website](https://clusterlabs.org) is an excellent place to learn more about Pacemaker clusters. @@ -159,9 +156,7 @@ more about Pacemaker clusters. * [Clusters from Scratch](https://clusterlabs.org/pacemaker/doc/2.1/Clusters_from_Scratch/html/) * [ClusterLabs documentation page](https://clusterlabs.org/pacemaker/doc/) ---- - -### Inquiries +## Inquiries If you have any bug reports or feature requests please feel free to open a github issue on the pcs project. diff --git a/configure.ac b/configure.ac index b4ef2682d..1db775351 100644 --- a/configure.ac +++ b/configure.ac @@ -129,7 +129,7 @@ fi # configure options section AC_ARG_ENABLE([dev-tests], - [AS_HELP_STRING([--enable-dev-tests], [Enable extra developers tests (black, isort, mypy, pylint) (default: no)])], + [AS_HELP_STRING([--enable-dev-tests], [Enable extra developers tests (mypy, ruff) (default: no)])], [dev_tests="yes"]) AM_CONDITIONAL([DEV_TESTS], [test "x$dev_tests" = "xyes"]) @@ -143,16 +143,6 @@ AC_ARG_ENABLE([concise-tests], [concise_tests="yes"]) AM_CONDITIONAL([CONCISE_TESTS], [test "x$concise_tests" = "xyes"]) -AC_ARG_ENABLE([parallel-pylint], - [AS_HELP_STRING([--enable-parallel-pylint], [Enable running pylint in multiple threads (default: no)])], - [parallel_pylint="yes"]) -AM_CONDITIONAL([PARALLEL_PYLINT], [test "x$parallel_pylint" = "xyes"]) - -AC_ARG_ENABLE([typos-check], - [AS_HELP_STRING([--enable-typos-check], [Enable checking source code for typos (needs https://github.com/crate-ci/typos to be installed) (default: no)])], - [typos_check="yes"]) -AM_CONDITIONAL([TYPOS_CHECK], [test "x$typos_check" = "xyes"]) - AC_ARG_ENABLE([local-build], [AS_HELP_STRING([--enable-local-build], [Download and install all dependencies as user / bundles])], [local_build="yes"]) @@ -199,7 +189,8 @@ if test "x$cache_only" != "xyes"; then fi fi -if test "x$typos_check" = "xyes"; then +if test "x$dev_tests" = "xyes"; then + # diff and sort is used in 'make typos_check' and 'make typos_known' AC_PATH_PROG([DIFF], [diff]) if test "x$DIFF" = "x"; then AC_MSG_ERROR([Unable to find diff in $PATH]) @@ -208,10 +199,6 @@ if test "x$typos_check" = "xyes"; then if test "x$SORT" = "x"; then AC_MSG_ERROR([Unable to find sort in $PATH]) fi - AC_PATH_PROG([TYPOS], [typos]) - if test "x$TYPOS" = "x"; then - AC_MSG_ERROR([Unable to find typos in $PATH]) - fi fi if test "x$DISTRO" = "x"; then @@ -240,7 +227,7 @@ for i in $DISTRO $DISTROS; do DISTROEXT=debian break ;; - fedora|rhel|centos|centos-stream*|opensuse*) + fedora*|rhel|centos|centos-stream*|opensuse*) FOUND_DISTRO=1 CONFIGDIR="$sysconfdir/sysconfig" PCSLIBDIR="$LIBDIR" @@ -288,6 +275,10 @@ AC_ARG_WITH([snmp-mibs-dir], [SNMP_MIB_DIR="$prefix/share/snmp/mibs"]) AC_SUBST([SNMP_MIB_DIR]) +AC_ARG_WITH([custom-gemfile], + [AS_HELP_STRING([--with-custom-gemfile=PATH], [Use custom gemfile instead of autogenerated. Effective only with the local-build option. Default: empty])], + [PCS_CUSTOM_GEMFILE="$withval"]) + # python detection section PCS_BUNDLED_DIR_LOCAL="pcs_bundled" AC_SUBST([PCS_BUNDLED_DIR_LOCAL]) @@ -371,12 +362,26 @@ AC_SUBST([PCSD_BUNDLED_DIR_LOCAL]) AC_SUBST([PCSD_BUNDLED_CACHE_DIR]) rm -rf Gemfile Gemfile.lock -echo "source 'https://rubygems.org'" > Gemfile -echo "" >> Gemfile + +if test "x$local_build" = "xyes"; then + if test "x$PCS_CUSTOM_GEMFILE" != "x"; then + if ! test -e "$PCS_CUSTOM_GEMFILE"; then + AC_MSG_ERROR([custom gemfile '$PCS_CUSTOM_GEMFILE' does not exist]) + fi + cp "$PCS_CUSTOM_GEMFILE" Gemfile + else + echo "source 'https://rubygems.org'" > Gemfile + echo "" >> Gemfile + fi +fi # PCS_BUNDLE_GEM([module]) AC_DEFUN([PCS_BUNDLE_GEM], [ - echo "gem '$1'" >> Gemfile + if test "x$PCS_CUSTOM_GEMFILE" = "x"; then + echo "gem '$1'" >> Gemfile + else + grep "$1" Gemfile || AC_MSG_ERROR([custom gemfile missing required gem '$1']) + fi if test "x$cache_only" = "xyes"; then src=`ls $PCSD_BUNDLED_CACHE_DIR/$1-*` if test "x$src" = "x"; then @@ -388,7 +393,10 @@ AC_DEFUN([PCS_BUNDLE_GEM], [ # PCS_CHECK_GEM([module], [version]) AC_DEFUN([PCS_CHECK_GEM], [ if test "x$local_build" = "xyes"; then - AC_RUBY_GEM([$1], [$2], [], [PCS_BUNDLE_GEM([$1])]) + AC_RUBY_GEM([$1], [$2], [bundle_module=no], [bundle_module=yes]) + if test "x$bundle_module" = "xyes" || test "x$individual_bundling" != "xyes"; then + PCS_BUNDLE_GEM([$1]) + fi else AC_RUBY_GEM([$1], [$2], [], [AC_MSG_ERROR([ruby gem $1 not found])]) fi @@ -396,7 +404,7 @@ AC_DEFUN([PCS_CHECK_GEM], [ # PCS_GEM_ACTION([curversion], [op], [cmpversion][, action-if-true] [, action-if-false]) AC_DEFUN([PCS_GEM_ACTION], [ - if test -n "$1"; then + if test "x$individual_bundling" = "xyes" || test "x$local_build" != "xyes" && test -n "$1"; then AC_COMPARE_VERSIONS([$1], [$2], [$3], [$4], [$5]) else true @@ -471,6 +479,12 @@ AC_SUBST([BOOTH_ENABLE_AUTHFILE_SET_ENABLED], [$(if test "x$booth_enable_authfil AC_SUBST([BOOTH_ENABLE_AUTHFILE_UNSET_ENABLED], [$(if test "x$booth_enable_authfile_unset" = "xyes"; then echo "True"; else echo "False"; fi)]) AC_SUBST([BOOTH_ENABLE_AUTHFILE_SET_CAPABILITY], [$(test "x$booth_enable_authfile_set" != "xyes"; echo "$?")]) AC_SUBST([BOOTH_ENABLE_AUTHFILE_UNSET_CAPABILITY], [$(test "x$booth_enable_authfile_unset" != "xyes"; echo "$?")]) +PCSD_PUBLIC_DIR="$LIB_DIR/pcsd/public" +AC_SUBST([PCSD_PUBLIC_DIR]) +PCSD_WEBUI_DIR="$PCSD_PUBLIC_DIR/ui" +AC_SUBST([PCSD_WEBUI_DIR]) +PCSD_UNIX_SOCKET="$LOCALSTATEDIR/run/pcsd.socket" +AC_SUBST([PCSD_UNIX_SOCKET]) OUTPUT_FORMAT_SYNTAX_DOC="\fB\-\-output\-format\fR text|cmd|json" OUTPUT_FORMAT_DESC_DOC="There are 3 formats of output available: 'cmd', 'json' and 'text', default is 'text'. Format 'text' is a human friendly output. Format 'cmd' prints pcs commands which can be used to recreate the same configuration. Format 'json' is a machine oriented output of the configuration." @@ -599,6 +613,7 @@ UTC_DATE=$($UTC_DATE_AT$SOURCE_EPOCH +'%F') AC_SUBST([UTC_DATE]) AC_CONFIG_FILES([Makefile + pcs.pc setup.py setup.cfg data/Makefile @@ -629,6 +644,7 @@ AC_CONFIG_FILES([pcs_test/pcs_for_tests], [chmod +x pcs_test/pcs_for_tests]) AC_CONFIG_FILES([pcs_test/suite], [chmod +x pcs_test/suite]) AC_CONFIG_FILES([pcs_test/tools/bin_mock/pcmk/crm_resource], [chmod +x pcs_test/tools/bin_mock/pcmk/crm_resource]) AC_CONFIG_FILES([pcs_test/tools/bin_mock/pcmk/pacemaker-fenced], [chmod +x pcs_test/tools/bin_mock/pcmk/pacemaker-fenced]) +AC_CONFIG_FILES([pcs_test/tools/bin_mock/pcmk/stonith_admin], [chmod +x pcs_test/tools/bin_mock/pcmk/stonith_admin]) AC_CONFIG_FILES([pcsd/pcsd], [chmod +x pcsd/pcsd]) AC_CONFIG_FILES([scripts/pcsd.sh], [chmod +x scripts/pcsd.sh]) diff --git a/dev_requirements.txt b/dev_requirements.txt index aaad92567..a89be0b90 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,10 +1,11 @@ lxml-stubs -pylint==3.1.0 -astroid==3.1.0 -mypy==1.9.0 -black==24.3.0 -isort +mypy==1.19.1 +ruff==0.14.11 +typos==1.41.0 types-cryptography types-dataclasses -types-pycurl +# later versions remove type annotations from a few functions causing +# error: Call to untyped function "getinfo" in typed context [no-untyped-call] +# so we are stuck with this version until there's a fix +types-pycurl==7.45.2.20240311 types-python-dateutil diff --git a/m4/ax_prog_date.m4 b/m4/ax_prog_date.m4 index c85f0f242..31d47b2dc 100644 --- a/m4/ax_prog_date.m4 +++ b/m4/ax_prog_date.m4 @@ -1,3 +1,6 @@ +# +# Customized version to support uutils from Ubuntu +# # =========================================================================== # https://www.gnu.org/software/autoconf-archive/ax_prog_date.html # =========================================================================== @@ -13,7 +16,7 @@ # # The type is determined as follow: # -# * If the version string contains "GNU", then: +# * If the version string contains "GNU" or "uutils", then: # - The variable ax_cv_prog_date_gnu is set to "yes". # - The variable ax_cv_prog_date_type is set to "gnu". # @@ -78,7 +81,7 @@ AC_DEFUN([AX_PROG_DATE], [dnl AC_CACHE_CHECK([for GNU date], [ax_cv_prog_date_gnu], [ ax_cv_prog_date_gnu=no - if date --version 2>/dev/null | head -1 | grep -q GNU + if date --version 2>/dev/null | head -1 | grep -q "\(GNU\|uutils\)" then ax_cv_prog_date_gnu=yes fi diff --git a/mypy.ini b/mypy.ini index 21a621350..927c1e16e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -33,6 +33,12 @@ disallow_untyped_calls = False disallow_untyped_defs = False +[mypy-pcs.alert] +disallow_untyped_defs = True +disallow_untyped_calls = True + + + [mypy-pcs.common.*] disallow_untyped_defs = True disallow_untyped_calls = True @@ -80,12 +86,28 @@ disallow_untyped_calls = True disallow_untyped_defs = False disallow_untyped_calls = False +[mypy-pcs.lib.booth.cib] +disallow_untyped_defs = True +disallow_untyped_calls = True + [mypy-pcs.lib.cluster_property] disallow_untyped_defs = True disallow_untyped_calls = True +[mypy-pcs.lib.cib.alert] +disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.cib.fencing_topology] +disallow_untyped_defs = True +disallow_untyped_calls = True + [mypy-pcs.lib.cib.nvpair_multi] disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.cib.remove_elements] +disallow_untyped_defs = True [mypy-pcs.lib.cib.resource.clone] disallow_untyped_defs = True @@ -93,6 +115,7 @@ disallow_untyped_calls = True [mypy-pcs.lib.cib.resource.common] disallow_untyped_defs = True +disallow_untyped_calls = True [mypy-pcs.lib.cib.resource.group] disallow_untyped_defs = True @@ -102,6 +125,9 @@ disallow_untyped_calls = True disallow_untyped_defs = True disallow_untyped_calls = True +[mypy-pcs.lib.cib.resource.stonith] +disallow_untyped_defs = True + [mypy-pcs.lib.cib.resource.relations] disallow_untyped_defs = True disallow_untyped_calls = True @@ -118,11 +144,63 @@ disallow_untyped_calls = True disallow_untyped_defs = True disallow_untyped_calls = True +[mypy-pcs.lib.cib.sections] +disallow_untyped_defs = True +disallow_untyped_calls = True + [mypy-pcs.lib.cib.tag] disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.cib.tools] +disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.commands.alert] +disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.commands.booth] +disallow_untyped_defs = True + +[mypy-pcs.lib.commands.cib] +disallow_untyped_defs = True [mypy-pcs.lib.commands.cib_options] disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.commands.cluster.*] +disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.commands.cluster.config] +disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.commands.cluster.link] +disallow_untyped_defs = False +disallow_untyped_calls = False + +[mypy-pcs.lib.commands.cluster.misc] +disallow_untyped_defs = True +disallow_untyped_calls = False + +[mypy-pcs.lib.commands.cluster.node] +disallow_untyped_defs = False +disallow_untyped_calls = False + +[mypy-pcs.lib.commands.cluster.setup_cluster] +disallow_untyped_defs = True +disallow_untyped_calls = False + +[mypy-pcs.lib.commands.cluster.setup_node] +disallow_untyped_defs = False +disallow_untyped_calls = False + +[mypy-pcs.lib.commands.cluster.setup_utils] +disallow_untyped_defs = False +disallow_untyped_calls = False [mypy-pcs.lib.commands.cluster_property] disallow_untyped_defs = True @@ -131,11 +209,19 @@ disallow_untyped_calls = True [mypy-pcs.lib.commands.dr] disallow_untyped_defs = True +[mypy-pcs.lib.commands.fencing_topology] +disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.commands.sbd] +disallow_untyped_defs = True + [mypy-pcs.lib.commands.status] disallow_untyped_defs = True [mypy-pcs.lib.commands.tag] disallow_untyped_defs = True +disallow_untyped_calls = True [mypy-pcs.lib.corosync.*] disallow_untyped_defs = True @@ -153,6 +239,14 @@ disallow_untyped_calls = True disallow_untyped_defs = True disallow_untyped_calls = True +[mypy-pcs.lib.pacemaker.*] +disallow_untyped_defs = True +disallow_untyped_calls = True + +[mypy-pcs.lib.pacemaker.state] +disallow_untyped_defs = False +disallow_untyped_calls = False + [mypy-pcs.lib.permissions.*] disallow_untyped_defs = True disallow_untyped_calls = True @@ -161,6 +255,10 @@ disallow_untyped_calls = True disallow_untyped_defs = True disallow_untyped_calls = True +[mypy-pcs.lib.sbd] +disallow_untyped_defs = True +disallow_untyped_calls = True + [mypy-pcs.lib.services] disallow_untyped_defs = True diff --git a/pcs.pc.in b/pcs.pc.in new file mode 100644 index 000000000..1a4af31f5 --- /dev/null +++ b/pcs.pc.in @@ -0,0 +1,6 @@ +webui_dir=@PCSD_WEBUI_DIR@ +pcsd_unix_socket=@PCSD_UNIX_SOCKET@ + +Name: pcs +Description: Pacemaker/Corosync Configuration System +Version: @VERSION@ diff --git a/pcs/Makefile.am b/pcs/Makefile.am index 707a07743..029e0603a 100644 --- a/pcs/Makefile.am +++ b/pcs/Makefile.am @@ -20,6 +20,9 @@ EXTRA_DIST = \ acl.py \ alert.py \ app.py \ + cli/alert/__init__.py \ + cli/alert/command.py \ + cli/alert/output.py \ cli/booth/command.py \ cli/booth/env.py \ cli/booth/__init__.py \ @@ -66,6 +69,9 @@ EXTRA_DIST = \ cli/file/__init__.py \ cli/file/metadata.py \ cli/__init__.py \ + cli/node/__init__.py \ + cli/node/command.py \ + cli/node/output.py \ cli/nvset.py \ cli/query/__init__.py \ cli/query/resource.py \ @@ -75,6 +81,8 @@ EXTRA_DIST = \ cli/reports/preprocessor.py \ cli/reports/processor.py \ cli/resource/__init__.py \ + cli/resource/command.py \ + cli/resource/common.py \ cli/resource/parse_args.py \ cli/resource/output.py \ cli/resource/relations.py \ @@ -102,8 +110,15 @@ EXTRA_DIST = \ cli/rule.py \ cli/status/command.py \ cli/status/__init__.py \ - cli/tag/command.py \ + cli/stonith/__init__.py \ + cli/stonith/command.py \ + cli/stonith/common.py \ + cli/stonith/levels/__init__.py \ + cli/stonith/levels/command.py \ + cli/stonith/levels/output.py \ cli/tag/__init__.py \ + cli/tag/command.py \ + cli/tag/output.py \ cluster.py \ common/corosync_conf.py \ common/const.py \ @@ -125,6 +140,7 @@ EXTRA_DIST = \ common/interface/__init__.py \ common/node_communicator.py \ common/pacemaker/__init__.py \ + common/pacemaker/alert.py \ common/pacemaker/cluster_property.py \ common/pacemaker/constraint/__init__.py \ common/pacemaker/constraint/all.py \ @@ -134,6 +150,8 @@ EXTRA_DIST = \ common/pacemaker/constraint/set.py \ common/pacemaker/constraint/ticket.py \ common/pacemaker/defaults.py \ + common/pacemaker/fencing_topology.py \ + common/pacemaker/node.py \ common/pacemaker/nvset.py \ common/pacemaker/resource/__init__.py \ common/pacemaker/resource/bundle.py \ @@ -145,6 +163,7 @@ EXTRA_DIST = \ common/pacemaker/resource/relations.py \ common/pacemaker/role.py \ common/pacemaker/rule.py \ + common/pacemaker/tag.py \ common/pacemaker/tools.py \ common/pacemaker/types.py \ common/permissions/__init__.py \ @@ -234,6 +253,7 @@ EXTRA_DIST = \ lib/auth/provider.py \ lib/auth/tools.py \ lib/auth/types.py \ + lib/booth/cib.py \ lib/booth/config_facade.py \ lib/booth/config_files.py \ lib/booth/config_parser.py \ @@ -258,8 +278,10 @@ EXTRA_DIST = \ lib/cib/fencing_topology.py \ lib/cib/__init__.py \ lib/cib/node.py \ + lib/cib/node_rename.py \ lib/cib/nvpair_multi.py \ lib/cib/nvpair.py \ + lib/cib/remove_elements.py \ lib/cib/resource/agent.py \ lib/cib/resource/bundle.py \ lib/cib/resource/clone.py \ @@ -296,8 +318,16 @@ EXTRA_DIST = \ lib/commands/booth.py \ lib/commands/cib.py \ lib/commands/cib_options.py \ - lib/commands/cluster.py \ + lib/commands/cluster/config.py \ + lib/commands/cluster/__init__.py \ + lib/commands/cluster/link.py \ + lib/commands/cluster/misc.py \ + lib/commands/cluster/node.py \ lib/commands/cluster_property.py \ + lib/commands/cluster/setup_cluster.py \ + lib/commands/cluster/setup_node.py \ + lib/commands/cluster/setup_utils.py \ + lib/commands/cluster/utils.py \ lib/commands/constraint/colocation.py \ lib/commands/constraint/common.py \ lib/commands/constraint/__init__.py \ @@ -385,6 +415,7 @@ EXTRA_DIST = \ lib/resource_agent/types.py \ lib/resource_agent/xml.py \ lib/sbd.py \ + lib/sbd_stonith.py \ lib/services.py \ lib/tools.py \ lib/validate.py \ diff --git a/pcs/acl.py b/pcs/acl.py index 4bfd6cc63..0c4214d0b 100644 --- a/pcs/acl.py +++ b/pcs/acl.py @@ -138,7 +138,7 @@ def argv_to_permission_info_list(argv): ) ) - for permission, scope_type, dummy_scope in permission_info_list: + for permission, scope_type, _ in permission_info_list: if permission not in ["read", "write", "deny"] or scope_type not in [ "xpath", "id", diff --git a/pcs/alert.py b/pcs/alert.py index ab3a1dcdc..db9411010 100644 --- a/pcs/alert.py +++ b/pcs/alert.py @@ -1,7 +1,9 @@ import json from typing import Any +from pcs.cli.alert.output import config_dto_to_lines from pcs.cli.common.errors import CmdLineInputError +from pcs.cli.common.output import lines_to_str from pcs.cli.common.parse_args import ( Argv, InputModifiers, @@ -9,7 +11,6 @@ group_by_keywords, ) from pcs.cli.reports.output import deprecation_warning -from pcs.common.str_tools import indent def alert_add(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: @@ -22,7 +23,7 @@ def alert_add(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: raise CmdLineInputError() sections = group_by_keywords( - argv, set(["options", "meta"]), implicit_first_keyword="main" + argv, {"options", "meta"}, implicit_first_keyword="main" ) parser = KeyValueParser(sections.get_args_flat("main")) parser.check_allowed_keys(["id", "description", "path"]) @@ -49,7 +50,7 @@ def alert_update(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: alert_id = argv[0] sections = group_by_keywords( - argv[1:], set(["options", "meta"]), implicit_first_keyword="main" + argv[1:], {"options", "meta"}, implicit_first_keyword="main" ) parser = KeyValueParser(sections.get_args_flat("main")) parser.check_allowed_keys(["description", "path"]) @@ -89,7 +90,7 @@ def recipient_add(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: alert_id = argv[0] sections = group_by_keywords( - argv[1:], set(["options", "meta"]), implicit_first_keyword="main" + argv[1:], {"options", "meta"}, implicit_first_keyword="main" ) parser = KeyValueParser(sections.get_args_flat("main")) parser.check_allowed_keys(["description", "id", "value"]) @@ -119,7 +120,7 @@ def recipient_update(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: recipient_id = argv[0] sections = group_by_keywords( - argv[1:], set(["options", "meta"]), implicit_first_keyword="main" + argv[1:], {"options", "meta"}, implicit_first_keyword="main" ) parser = KeyValueParser(sections.get_args_flat("main")) parser.check_allowed_keys(["description", "value"]) @@ -147,82 +148,17 @@ def recipient_remove(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: lib.alert.remove_recipient(argv) -def _nvset_to_str(nvset_obj): - # TODO duplicite to pcs.resource._nvpairs_strings - key_val = { - nvpair_obj["name"]: nvpair_obj["value"] for nvpair_obj in nvset_obj - } - output = [] - for name, value in sorted(key_val.items()): - if " " in value: - value = f'"{value}"' - output.append(f"{name}={value}") - return " ".join(output) - - -def __description_attributes_to_str(obj): - output = [] - if obj.get("description"): - output.append(f"Description: {obj['description']}") - if obj.get("instance_attributes"): - attributes = _nvset_to_str(obj["instance_attributes"]) - output.append(f"Options: {attributes}") - if obj.get("meta_attributes"): - attributes = _nvset_to_str(obj["meta_attributes"]) - output.append(f"Meta options: {attributes}") - return output - - -def _alert_to_str(alert): - content = [] - content.extend(__description_attributes_to_str(alert)) - - recipients = [] - for recipient in alert.get("recipient_list", []): - recipients.extend(_recipient_to_str(recipient)) - - if recipients: - content.append("Recipients:") - content.extend(indent(recipients, 1)) - - return [f"Alert: {alert['id']} (path={alert['path']})"] + indent(content, 1) - - -def _recipient_to_str(recipient): - return [ - f"Recipient: {recipient['id']} (value={recipient['value']})" - ] + indent(__description_attributes_to_str(recipient), 1) - - def print_alert_show(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: deprecation_warning( "This command is deprecated and will be removed. " "Please use 'pcs alert config' instead." ) - return print_alert_config(lib, argv, modifiers) - - -def print_alert_config(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: - """ - Options: - * -f - CIB file (in lib wrapper) - """ modifiers.ensure_only_supported("-f") if argv: raise CmdLineInputError() - lines = alert_config_lines(lib) - if lines: - print("\n".join(lines)) - - -def alert_config_lines(lib: Any) -> list[str]: - lines = [] - alert_list = lib.alert.get_all_alerts() - if alert_list: - lines.append("Alerts:") - for alert in alert_list: - lines.extend(indent(_alert_to_str(alert), 1)) - return lines + result_text = lines_to_str(config_dto_to_lines(lib.alert.get_config_dto())) + if result_text: + print(result_text) def print_alerts_in_json( diff --git a/pcs/app.py b/pcs/app.py index fac169e86..02636d10c 100644 --- a/pcs/app.py +++ b/pcs/app.py @@ -118,7 +118,7 @@ def _non_root_run(argv_cmd): filename = "" -def main(argv=None): +def main(argv=None): # noqa: PLR0912, PLR0915 # pylint: disable=global-statement # pylint: disable=too-many-branches # pylint: disable=too-many-locals @@ -133,7 +133,7 @@ def main(argv=None): argv = argv if argv else sys.argv[1:] utils.subprocess_setup() - global filename, usefile + global filename, usefile # noqa: PLW0603 utils.pcs_options = {} # we want to support optional arguments for --wait, so if an argument @@ -145,7 +145,8 @@ def main(argv=None): tempsecs = arg.replace("--wait=", "") if tempsecs: waitsecs = tempsecs - arg = "--wait" + new_argv.append("--wait") + continue new_argv.append(arg) argv = new_argv @@ -181,13 +182,13 @@ def main(argv=None): sys.exit(1) full = False - for option, dummy_value in pcs_options: + for option, _ in pcs_options: if option == "--full": full = True break for opt, val in pcs_options: - if not opt in utils.pcs_options: + if opt not in utils.pcs_options: utils.pcs_options[opt] = val else: # If any options are a list then they've been entered twice which diff --git a/pcs/cli/alert/__init__.py b/pcs/cli/alert/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pcs/cli/alert/command.py b/pcs/cli/alert/command.py new file mode 100644 index 000000000..546825789 --- /dev/null +++ b/pcs/cli/alert/command.py @@ -0,0 +1,42 @@ +import json +from typing import Any + +from pcs.cli.common.errors import CmdLineInputError +from pcs.cli.common.output import lines_to_str +from pcs.cli.common.parse_args import ( + OUTPUT_FORMAT_VALUE_CMD, + OUTPUT_FORMAT_VALUE_JSON, + Argv, + InputModifiers, +) +from pcs.common.interface.dto import to_dict + +from .output import config_dto_to_cmd, config_dto_to_lines + + +def alert_config(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: + """ + Options: + * -f - CIB file + * --output-format - supported formats: text, cmd, json + """ + modifiers.ensure_only_supported("-f", output_format_supported=True) + output_format = modifiers.get_output_format() + if argv: + raise CmdLineInputError + + config_dto = lib.alert.get_config_dto() + + if output_format == OUTPUT_FORMAT_VALUE_JSON: + print(json.dumps(to_dict(config_dto), indent=2)) + return + + if output_format == OUTPUT_FORMAT_VALUE_CMD: + result_cmd = config_dto_to_cmd(config_dto) + if result_cmd: + print(";\n".join(result_cmd)) + return + + result_text = lines_to_str(config_dto_to_lines(config_dto)) + if result_text: + print(result_text) diff --git a/pcs/cli/alert/output.py b/pcs/cli/alert/output.py new file mode 100644 index 000000000..5ee55351e --- /dev/null +++ b/pcs/cli/alert/output.py @@ -0,0 +1,141 @@ +import shlex +from typing import Optional, Sequence, Union + +from pcs.cli.common.output import ( + INDENT_STEP, + pairs_to_cmd, +) +from pcs.cli.nvset import nvset_dto_to_lines +from pcs.common.pacemaker.alert import ( + CibAlertDto, + CibAlertListDto, + CibAlertRecipientDto, + CibAlertSelectDto, +) +from pcs.common.pacemaker.nvset import CibNvsetDto +from pcs.common.str_tools import ( + format_list, + format_optional, + indent, +) + + +def _description_to_lines(desc: Optional[str]) -> list[str]: + return [f"Description: {desc}"] if desc else [] + + +def _nvsets_to_lines(label: str, nvsets: Sequence[CibNvsetDto]) -> list[str]: + if nvsets and nvsets[0].nvpairs: + return nvset_dto_to_lines(nvset=nvsets[0], nvset_label=label) + return [] + + +def _recipient_dto_to_lines(recipient_dto: CibAlertRecipientDto) -> list[str]: + lines = ( + _description_to_lines(recipient_dto.description) + + [f"Value: {recipient_dto.value}"] + + _nvsets_to_lines("Attributes", recipient_dto.instance_attributes) + + _nvsets_to_lines("Meta Attributes", recipient_dto.meta_attributes) + ) + return [f"Recipient: {recipient_dto.id}"] + indent( + lines, indent_step=INDENT_STEP + ) + + +def _recipients_to_lines( + recipient_dto_list: Sequence[CibAlertRecipientDto], +) -> list[str]: + if not recipient_dto_list: + return [] + lines = [] + for recipient_dto in recipient_dto_list: + lines.extend(_recipient_dto_to_lines(recipient_dto)) + return ["Recipients:"] + indent(lines, indent_step=INDENT_STEP) + + +def _select_dto_to_lines(select_dto: Optional[CibAlertSelectDto]) -> list[str]: + if not select_dto: + return [] + lines = [] + if select_dto.nodes: + lines.append("nodes") + if select_dto.fencing: + lines.append("fencing") + if select_dto.resources: + lines.append("resources") + if select_dto.attributes: + attr_names = format_list( + attr.name for attr in select_dto.attributes_select + ) + lines.append("attributes" + format_optional(attr_names, ": {}")) + return ["Receives:"] + indent(lines, indent_step=INDENT_STEP) + + +def alert_dto_to_lines(alert_dto: CibAlertDto) -> list[str]: + lines = ( + _description_to_lines(alert_dto.description) + + [f"Path: {alert_dto.path}"] + + _recipients_to_lines(alert_dto.recipients) + + _select_dto_to_lines(alert_dto.select) + + _nvsets_to_lines("Attributes", alert_dto.instance_attributes) + + _nvsets_to_lines("Meta Attributes", alert_dto.meta_attributes) + ) + return [f"Alert: {alert_dto.id}"] + indent(lines, indent_step=INDENT_STEP) + + +def config_dto_to_lines(config_dto: CibAlertListDto) -> list[str]: + result = [] + for alert_dto in config_dto.alerts: + result.extend(alert_dto_to_lines(alert_dto)) + return result + + +def config_dto_to_cmd(config_dto: CibAlertListDto) -> list[str]: + commands = [] + for alert_dto in config_dto.alerts: + # alert + alert_parts = [ + "pcs -- alert create path={path} id={id}".format( + path=shlex.quote(alert_dto.path), id=shlex.quote(alert_dto.id) + ) + ] + _desc_instance_meta_to_cmd(alert_dto) + # TODO export select, once it is supported by pcs + commands.append(" ".join(alert_parts)) + # recipients + for recipient_dto in alert_dto.recipients: + recipient_parts = [ + "pcs -- alert recipient add {alert_id} value={value} id={id}".format( + alert_id=shlex.quote(alert_dto.id), + value=shlex.quote(recipient_dto.value), + id=shlex.quote(recipient_dto.id), + ) + ] + _desc_instance_meta_to_cmd(recipient_dto) + commands.append(" ".join(recipient_parts)) + return commands + + +def _nvset_to_cmd( + label: Optional[str], + nvsets: Sequence[CibNvsetDto], +) -> list[str]: + if nvsets and nvsets[0].nvpairs: + options = pairs_to_cmd( + (nvpair.name, nvpair.value) for nvpair in nvsets[0].nvpairs + ) + if label: + options = f"{label} {options}" + return [options] + return [] + + +def _desc_instance_meta_to_cmd( + dto: Union[CibAlertDto, CibAlertRecipientDto], +) -> list[str]: + parts = [] + if dto.description: + parts.append( + "description={desc}".format(desc=shlex.quote(dto.description)) + ) + parts.extend(_nvset_to_cmd("options", dto.instance_attributes)) + parts.extend(_nvset_to_cmd("meta", dto.meta_attributes)) + return parts diff --git a/pcs/cli/booth/command.py b/pcs/cli/booth/command.py index 4b0183025..6a9f68e89 100644 --- a/pcs/cli/booth/command.py +++ b/pcs/cli/booth/command.py @@ -10,6 +10,7 @@ KeyValueParser, group_by_keywords, ) +from pcs.common.reports import codes as report_codes def config_setup(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: @@ -28,7 +29,7 @@ def config_setup(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: "--booth-key", "--name", ) - peers = group_by_keywords(arg_list, set(["sites", "arbitrators"])) + peers = group_by_keywords(arg_list, {"sites", "arbitrators"}) peers.ensure_unique_keywords() if not peers.has_keyword("sites") or not peers.get_args_flat("sites"): raise CmdLineInputError() @@ -191,6 +192,38 @@ def ticket_grant(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: ) +def ticket_cleanup(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: + """ + Options: None + """ + modifiers.ensure_only_supported() + if len(arg_list) != 1: + raise CmdLineInputError() + lib.booth.ticket_cleanup(arg_list[0]) + + +def ticket_unstandby( + lib: Any, arg_list: Argv, modifiers: InputModifiers +) -> None: + """ + Options: None + """ + modifiers.ensure_only_supported() + if len(arg_list) != 1: + raise CmdLineInputError() + lib.booth.ticket_unstandby(arg_list[0]) + + +def ticket_standby(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: + """ + Options: None + """ + modifiers.ensure_only_supported() + if len(arg_list) != 1: + raise CmdLineInputError() + lib.booth.ticket_standby(arg_list[0]) + + def create_in_cluster( lib: Any, arg_list: Argv, modifiers: InputModifiers ) -> None: @@ -211,53 +244,42 @@ def create_in_cluster( ) -def get_remove_from_cluster(resource_remove): # type:ignore - # TODO resource_remove is provisional hack until resources are not moved to - # lib - def remove_from_cluster( - lib: Any, arg_list: Argv, modifiers: InputModifiers - ) -> None: - """ - Options: - * --force - allow remove of multiple - * -f - CIB file - * --name - name of a booth instance - """ - modifiers.ensure_only_supported("--force", "-f", "--name") - if arg_list: - raise CmdLineInputError() - - lib.booth.remove_from_cluster( - resource_remove, - instance_name=modifiers.get("--name"), - allow_remove_multiple=modifiers.get("--force"), - ) +def remove_from_cluster( + lib: Any, arg_list: Argv, modifiers: InputModifiers +) -> None: + """ + Options: + * --force - allow remove of multiple + * -f - CIB file + * --name - name of a booth instance + """ + modifiers.ensure_only_supported("--force", "-f", "--name") + if arg_list: + raise CmdLineInputError() - return remove_from_cluster - - -def get_restart(resource_restart): # type:ignore - # TODO resource_restart is provisional hack until resources are not moved to - # lib - def restart(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: - """ - Options: - * --force - allow multiple - * --name - name of a booth instance - """ - modifiers.ensure_only_supported("--force", "--name") - if arg_list: - raise CmdLineInputError() - - lib.booth.restart( - lambda resource_id_list: resource_restart( - lib, resource_id_list, modifiers.get_subset("--force") - ), - instance_name=modifiers.get("--name"), - allow_multiple=modifiers.get("--force"), - ) + force_flags = [] + if modifiers.get("--force"): + force_flags.append(report_codes.FORCE) + + lib.booth.remove_from_cluster( + instance_name=modifiers.get("--name"), force_flags=force_flags + ) - return restart + +def restart(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: + """ + Options: + * --force - allow multiple + * --name - name of a booth instance + """ + modifiers.ensure_only_supported("--force", "--name") + if arg_list: + raise CmdLineInputError() + + lib.booth.restart( + instance_name=modifiers.get("--name"), + allow_multiple=modifiers.get("--force"), + ) def sync(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: diff --git a/pcs/cli/booth/env.py b/pcs/cli/booth/env.py index 16edb73a2..1f9520d3a 100644 --- a/pcs/cli/booth/env.py +++ b/pcs/cli/booth/env.py @@ -22,6 +22,8 @@ def middleware_config(config_path, key_path): ) is_mocked_environment = config_path and key_path + config_file = None + key_file = None if is_mocked_environment: config_file = pcs_file.RawFile( metadata.for_file_type(file_type_codes.BOOTH_CONFIG, config_path) @@ -32,8 +34,14 @@ def middleware_config(config_path, key_path): def create_booth_env(): try: - config_data = config_file.read() if config_file.exists() else None - key_data = key_file.read() if key_file.exists() else None + config_data = ( + config_file.read() + if config_file and config_file.exists() + else None + ) + key_data = ( + key_file.read() if key_file and key_file.exists() else None + ) # TODO write custom error handling, do not use pcs.lib specific code # and LibraryError except pcs_file.RawFileError as e: diff --git a/pcs/cli/cluster/command.py b/pcs/cli/cluster/command.py index 8df36b881..0e814c82f 100644 --- a/pcs/cli/cluster/command.py +++ b/pcs/cli/cluster/command.py @@ -12,6 +12,7 @@ from pcs.cli.resource.parse_args import ( parse_primitive as parse_primitive_resource, ) +from pcs.common.reports import codes as report_codes def _node_add_remote_separate_name_and_addr( @@ -83,39 +84,37 @@ def node_add_remote( ) -def create_node_remove_remote(remove_resource): # type:ignore - def node_remove_remote( - lib: Any, arg_list: Argv, modifiers: InputModifiers - ) -> None: - """ - Options: - * --force - allow multiple nodes removal, allow pcmk remote service - to fail, don't stop a resource before its deletion (this is side - effect of old resource delete command used here) - * --skip-offline - skip offline nodes - * --request-timeout - HTTP request timeout - For tests: - * --corosync_conf - * -f - """ - modifiers.ensure_only_supported( - "--force", - "--skip-offline", - "--request-timeout", - "--corosync_conf", - "-f", - ) - if len(arg_list) != 1: - raise CmdLineInputError() - lib.remote_node.node_remove_remote( - arg_list[0], - remove_resource, - skip_offline_nodes=modifiers.get("--skip-offline"), - allow_remove_multiple_nodes=modifiers.get("--force"), - allow_pacemaker_remote_service_fail=modifiers.get("--force"), - ) - - return node_remove_remote +def node_remove_remote( + lib: Any, arg_list: Argv, modifiers: InputModifiers +) -> None: + """ + Options: + * --force - allow multiple nodes removal, allow pcmk remote service + to fail, don't stop a resource before its deletion (this is side + effect of old resource delete command used here) + * --skip-offline - skip offline nodes + * --request-timeout - HTTP request timeout + For tests: + * --corosync_conf + * -f + """ + modifiers.ensure_only_supported( + "--force", + "--skip-offline", + "--request-timeout", + "--corosync_conf", + "-f", + ) + if len(arg_list) != 1: + raise CmdLineInputError() + + force_flags = [] + if modifiers.get("--force"): + force_flags.append(report_codes.FORCE) + if modifiers.get("--skip-offline"): + force_flags.append(report_codes.SKIP_OFFLINE_NODES) + + lib.remote_node.node_remove_remote(arg_list[0], force_flags) def node_add_guest(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: @@ -202,3 +201,52 @@ def node_clear(lib: Any, arg_list: Argv, modifiers: InputModifiers) -> None: lib.cluster.node_clear( arg_list[0], allow_clear_cluster_node=modifiers.get("--force") ) + + +def node_rename_cib(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: + """ + Options: + * -f - CIB file + * --force + """ + modifiers.ensure_only_supported("-f", "--force") + if len(argv) != 2: + raise CmdLineInputError() + force_flags = [] + if modifiers.get("--force"): + force_flags.append(report_codes.FORCE) + lib.cluster.rename_node_cib(argv[0], argv[1], force_flags) + + +def node_rename_corosync( + lib: Any, argv: Argv, modifiers: InputModifiers +) -> None: + """ + Options: + * --skip-offline - skip offline nodes + """ + modifiers.ensure_only_supported("--skip-offline") + if len(argv) != 2: + raise CmdLineInputError() + force_flags = [] + if modifiers.get("--skip-offline"): + force_flags.append(report_codes.SKIP_OFFLINE_NODES) + lib.cluster.rename_node_corosync(argv[0], argv[1], force_flags) + + +def cluster_rename(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: + """ + Options: + * --force + * --skip-offline - skip offline nodes + """ + modifiers.ensure_only_supported("--force", "--skip-offline") + if len(argv) != 1: + raise CmdLineInputError() + force_flags = [] + if modifiers.get("--force"): + force_flags.append(report_codes.FORCE) + if modifiers.get("--skip-offline"): + force_flags.append(report_codes.SKIP_OFFLINE_NODES) + + lib.cluster.rename(argv[0], force_flags) diff --git a/pcs/cli/cluster_property/command.py b/pcs/cli/cluster_property/command.py index 9229ca70a..70e57398b 100644 --- a/pcs/cli/cluster_property/command.py +++ b/pcs/cli/cluster_property/command.py @@ -26,10 +26,7 @@ from pcs.common.interface import dto from pcs.common.pacemaker.cluster_property import ClusterPropertyMetadataDto from pcs.common.pacemaker.nvset import ListCibNvsetDto -from pcs.common.str_tools import ( - format_list, - format_plural, -) +from pcs.common.str_tools import format_list, format_plural def set_property(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: @@ -63,9 +60,7 @@ def unset_property(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: else: ensure_unique_args(argv) - lib.cluster_property.set_properties( - {name: "" for name in argv}, force_flags - ) + lib.cluster_property.set_properties(dict.fromkeys(argv, ""), force_flags) def list_property_deprecated( diff --git a/pcs/cli/common/completion.py b/pcs/cli/common/completion.py index f42017bf7..db3798558 100644 --- a/pcs/cli/common/completion.py +++ b/pcs/cli/common/completion.py @@ -120,4 +120,4 @@ def _get_subcommands( if subcommand not in subcommand_tree: return [] subcommand_tree = subcommand_tree[subcommand] - return sorted(list(subcommand_tree.keys())) + return sorted(subcommand_tree.keys()) diff --git a/pcs/cli/common/lib_wrapper.py b/pcs/cli/common/lib_wrapper.py index 06a812d9e..a65023ad4 100644 --- a/pcs/cli/common/lib_wrapper.py +++ b/pcs/cli/common/lib_wrapper.py @@ -94,14 +94,14 @@ def decorated_run(*args, **kwargs): def bind_all(env, run_with_middleware, dictionary): return wrapper( - dict( - (exposed_fn, bind(env, run_with_middleware, library_fn)) + { + exposed_fn: bind(env, run_with_middleware, library_fn) for exposed_fn, library_fn in dictionary.items() - ) + } ) -def load_module(env, middleware_factory, name): +def load_module(env, middleware_factory, name): # noqa: PLR0911, PLR0912 # pylint: disable=too-many-branches # pylint: disable=too-many-return-statements if name == "acl": @@ -139,28 +139,32 @@ def load_module(env, middleware_factory, name): "update_recipient": alert.update_recipient, "remove_recipient": alert.remove_recipient, "get_all_alerts": alert.get_all_alerts, + "get_config_dto": alert.get_config_dto, }, ) if name == "booth": bindings = { - "config_setup": booth.config_setup, "config_destroy": booth.config_destroy, + "config_setup": booth.config_setup, + "config_sync": booth.config_sync, "config_text": booth.config_text, "config_ticket_add": booth.config_ticket_add, "config_ticket_remove": booth.config_ticket_remove, "create_in_cluster": booth.create_in_cluster, + "disable_booth": booth.disable_booth, + "enable_booth": booth.enable_booth, + "get_status": booth.get_status, + "pull_config": booth.pull_config, "remove_from_cluster": booth.remove_from_cluster, "restart": booth.restart, - "config_sync": booth.config_sync, - "enable_booth": booth.enable_booth, - "disable_booth": booth.disable_booth, "start_booth": booth.start_booth, "stop_booth": booth.stop_booth, - "pull_config": booth.pull_config, - "get_status": booth.get_status, + "ticket_cleanup": booth.ticket_cleanup, "ticket_grant": booth.ticket_grant, "ticket_revoke": booth.ticket_revoke, + "ticket_standby": booth.ticket_standby, + "ticket_unstandby": booth.ticket_unstandby, } if settings.booth_enable_authfile_set_enabled: bindings["config_set_enable_authfile"] = ( @@ -202,6 +206,9 @@ def load_module(env, middleware_factory, name): "remove_links": cluster.remove_links, "remove_nodes": cluster.remove_nodes, "remove_nodes_from_cib": cluster.remove_nodes_from_cib, + "rename": cluster.rename, + "rename_node_cib": cluster.rename_node_cib, + "rename_node_corosync": cluster.rename_node_corosync, "setup": cluster.setup, "setup_local": cluster.setup_local, "update_link": cluster.update_link, @@ -284,6 +291,7 @@ def load_module(env, middleware_factory, name): { "add_level": fencing_topology.add_level, "get_config": fencing_topology.get_config, + "get_config_dto": fencing_topology.get_config_dto, "remove_all_levels": fencing_topology.remove_all_levels, "remove_levels_by_params": ( fencing_topology.remove_levels_by_params @@ -297,6 +305,7 @@ def load_module(env, middleware_factory, name): env, middleware.build(middleware_factory.cib), { + "get_config_dto": node.get_config_dto, "maintenance_unmaintenance_all": ( node.maintenance_unmaintenance_all ), @@ -405,15 +414,17 @@ def load_module(env, middleware_factory, name): "enable": resource.enable, "get_configured_resources": resource.get_configured_resources, "get_failcounts": resource.get_failcounts, + "get_resource_relations_tree": ( + resource.get_resource_relations_tree + ), "group_add": resource.group_add, "is_any_resource_except_stonith": resource.is_any_resource_except_stonith, "is_any_stonith": resource.is_any_stonith, "manage": resource.manage, + "update_meta": resource.update_meta, "move": resource.move, "move_autoclean": resource.move_autoclean, - "get_resource_relations_tree": ( - resource.get_resource_relations_tree - ), + "restart": resource.restart, "unmanage": resource.unmanage, "unmove_unban": resource.unmove_unban, }, @@ -530,6 +541,7 @@ def load_module(env, middleware_factory, name): { "config": tag.config, "create": tag.create, + "get_config_dto": tag.get_config_dto, "remove": tag.remove, "update": tag.update, }, diff --git a/pcs/cli/common/output.py b/pcs/cli/common/output.py index 179f7c03f..817d5ce59 100644 --- a/pcs/cli/common/output.py +++ b/pcs/cli/common/output.py @@ -56,9 +56,11 @@ def format_wrap_for_terminal( trim -- number which will be substracted from terminal size. Can be used in cases lines will be indented later by this number of spaces. """ - if (sys.stdout is not None and sys.stdout.isatty()) or ( - sys.stderr is not None and sys.stderr.isatty() - ): + # This function is used for stdout only - we don't care about wrapping + # error messages and debug info. So it checks stdout and not stderr. + # Checking stderr would enable wrapping in case of 'pcs ... | grep ...' + # (stderr is connected to a terminal), which we don't want. (RHEL-36514) + if sys.stdout is not None and sys.stdout.isatty(): return format_wrap( text, # minimal line length is 40 @@ -94,3 +96,7 @@ def pairs_to_cmd(pairs: Iterable[tuple[str, str]]) -> str: def lines_to_str(lines: StringSequence) -> str: return "\n".join(smart_wrap_text(lines)) + + +def format_cmd_list(cmd_lines: StringSequence) -> str: + return ";\n".join(cmd_lines) diff --git a/pcs/cli/common/parse_args.py b/pcs/cli/common/parse_args.py index 552bca209..af62a1765 100644 --- a/pcs/cli/common/parse_args.py +++ b/pcs/cli/common/parse_args.py @@ -270,7 +270,7 @@ def __init__(self, groups: Mapping[str, list[Argv]]): def allow_repetition_only_for(self, keyword_set: StringCollection) -> None: """ - Raise CmdLineInputError if a keyword has been repetead when not allowed + Raise CmdLineInputError if a keyword has been repeated when not allowed keyword_set -- repetition is allowed for these keywords """ @@ -282,7 +282,7 @@ def allow_repetition_only_for(self, keyword_set: StringCollection) -> None: def ensure_unique_keywords(self) -> None: """ - Raise CmdLineInputError if any keyword has been repetead + Raise CmdLineInputError if any keyword has been repeated """ return self.allow_repetition_only_for(set()) @@ -647,7 +647,7 @@ def ensure_not_incompatible( checked -- option incompatible with any of incompatible options incompatible -- set of options incompatible with checked """ - if not checked in self._defined_options: + if checked not in self._defined_options: return disallowed = self._defined_options & set(incompatible) if disallowed: @@ -682,10 +682,7 @@ def is_specified(self, option: str) -> bool: return option in self._defined_options def is_specified_any(self, option_list: StringIterable) -> bool: - for option in option_list: - if self.is_specified(option): - return True - return False + return any(self.is_specified(option) for option in option_list) def get( self, option: str, default: ModifierValueType = None diff --git a/pcs/cli/common/printable_tree.py b/pcs/cli/common/printable_tree.py index 3a9525279..ef32b3db9 100644 --- a/pcs/cli/common/printable_tree.py +++ b/pcs/cli/common/printable_tree.py @@ -39,8 +39,7 @@ def tree_to_lines( _indent = "| " if not node.members: _indent = " " - for line in node.detail: - result.append(f"{indent}{_indent}{line}") + result.extend(f"{indent}{_indent}{line}" for line in node.detail) _indent = "| " _title_prefix = "|- " for member in node.members: diff --git a/pcs/cli/common/tools.py b/pcs/cli/common/tools.py index f06539e17..6811f6a78 100644 --- a/pcs/cli/common/tools.py +++ b/pcs/cli/common/tools.py @@ -5,7 +5,7 @@ def timeout_to_seconds_legacy( - timeout: Union[int, str] + timeout: Union[int, str], ) -> Union[int, str, None]: """ Transform pacemaker style timeout to number of seconds. If timeout is not diff --git a/pcs/cli/constraint/command.py b/pcs/cli/constraint/command.py index cb02d6d90..9677fd3c8 100644 --- a/pcs/cli/constraint/command.py +++ b/pcs/cli/constraint/command.py @@ -24,7 +24,7 @@ def create_with_set( ) -> None: """ callable create_with_set_library_call create constraint with set - argv -- part of comandline args + argv -- part of commandline args modifiers -- can contain "force" allowing resources in clone/promotable and constraint duplicity diff --git a/pcs/cli/constraint/output/__init__.py b/pcs/cli/constraint/output/__init__.py index 92bbc4823..a68d54706 100644 --- a/pcs/cli/constraint/output/__init__.py +++ b/pcs/cli/constraint/output/__init__.py @@ -8,5 +8,6 @@ CibConstraintLocationAnyDto, constraints_to_cmd, constraints_to_text, + filter_constraints_by_rule_expired_status, print_config, ) diff --git a/pcs/cli/constraint/output/all.py b/pcs/cli/constraint/output/all.py index 51023cea9..3f8256783 100644 --- a/pcs/cli/constraint/output/all.py +++ b/pcs/cli/constraint/output/all.py @@ -113,7 +113,7 @@ def _filter_out_expired_base( ] -def _filter_constraints( +def filter_constraints_by_rule_expired_status( constraints_dto: CibConstraintsDto, include_expired: bool ) -> CibConstraintsDto: return CibConstraintsDto( @@ -139,7 +139,7 @@ def _filter_constraints( def print_config( constraints_dto: CibConstraintsDto, modifiers: InputModifiers ) -> None: - constraints_dto = _filter_constraints( + constraints_dto = filter_constraints_by_rule_expired_status( constraints_dto, include_expired=modifiers.is_specified("--all"), ) diff --git a/pcs/cli/constraint/output/colocation.py b/pcs/cli/constraint/output/colocation.py index 7a13ed27b..7d4365b4b 100644 --- a/pcs/cli/constraint/output/colocation.py +++ b/pcs/cli/constraint/output/colocation.py @@ -39,9 +39,14 @@ def _attributes_to_pairs( def _attributes_to_text( attributes_dto: CibConstraintColocationAttributesDto, with_id: bool, + extra_attributes: Iterable[tuple[str, str]] = (), ) -> list[str]: result = [ - " ".join(format_name_value_list(_attributes_to_pairs(attributes_dto))) + " ".join( + format_name_value_list( + _attributes_to_pairs(attributes_dto) + list(extra_attributes) + ) + ) ] if attributes_dto.lifetime: result.append("Lifetime:") @@ -72,9 +77,16 @@ def plain_constraint_to_text( ] if with_id: result[0] += f" (id: {constraint_dto.attributes.constraint_id})" + extra_attributes = [] + if constraint_dto.node_attribute: + extra_attributes += [ + ("node-attribute", constraint_dto.node_attribute), + ] result.extend( indent( - _attributes_to_text(constraint_dto.attributes, with_id), + _attributes_to_text( + constraint_dto.attributes, with_id, extra_attributes + ), indent_step=INDENT_STEP, ) ) @@ -167,7 +179,7 @@ def plain_constraint_to_cmd( return [] if constraint_dto.node_attribute is not None: warn( - "Option 'node_attribute' detected in constraint " + "Option 'node-attribute' detected in constraint " f"'{constraint_dto.attributes.constraint_id}' but not supported by " "this command." " Command for creating the constraint is omitted." diff --git a/pcs/cli/constraint/output/set.py b/pcs/cli/constraint/output/set.py index 5395ebf7a..5335fdf47 100644 --- a/pcs/cli/constraint/output/set.py +++ b/pcs/cli/constraint/output/set.py @@ -1,7 +1,4 @@ -from typing import ( - Optional, - Sequence, -) +from typing import Optional, Sequence from pcs.cli.common.output import ( INDENT_STEP, @@ -12,7 +9,7 @@ from pcs.cli.reports.output import warn from pcs.common.pacemaker.constraint import CibResourceSetDto from pcs.common.str_tools import ( - format_list, + format_list_dont_sort, format_optional, indent, pairs_to_text, @@ -56,7 +53,7 @@ def resource_set_to_text( ] set_options = [ "Resources: {resources}".format( - resources=format_list(resource_set_dto.resources_ids) + resources=format_list_dont_sort(resource_set_dto.resources_ids) ) ] + pairs_to_text(_resource_set_options_to_pairs(resource_set_dto)) output.extend(indent(set_options, indent_step=INDENT_STEP)) diff --git a/pcs/cli/constraint/parse_args.py b/pcs/cli/constraint/parse_args.py index c79f012e3..c23791e77 100644 --- a/pcs/cli/constraint/parse_args.py +++ b/pcs/cli/constraint/parse_args.py @@ -13,7 +13,7 @@ def prepare_resource_sets( ) -> list[dict[str, Union[list[str], dict[str, str]]]]: return [ { - "ids": [id for id in args if "=" not in id], + "ids": [id_ for id_ in args if "=" not in id_], "options": KeyValueParser( [opt for opt in args if "=" in opt] ).get_unique(), diff --git a/pcs/cli/constraint/rule/command.py b/pcs/cli/constraint/rule/command.py index 056aa9458..a94f63f80 100644 --- a/pcs/cli/constraint/rule/command.py +++ b/pcs/cli/constraint/rule/command.py @@ -6,6 +6,7 @@ InputModifiers, ensure_unique_args, ) +from pcs.cli.reports.output import deprecation_warning from pcs.common.pacemaker.constraint import get_all_location_rules_ids from pcs.common.str_tools import format_list @@ -15,6 +16,13 @@ def remove(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: Options: * -f - CIB file """ + # deprecated in pacemaker 2, removed in pacemaker 3 + # added to pcs after 0.11.8 + # the whole command removed in pcs-0.12 + deprecation_warning( + "The possibility of defining multiple rules in a single location " + "constraint is deprecated and will be removed." + ) modifiers.ensure_only_supported("-f") if not argv: raise CmdLineInputError() diff --git a/pcs/cli/constraint_colocation/command.py b/pcs/cli/constraint_colocation/command.py index abf8039d9..b208e76ad 100644 --- a/pcs/cli/constraint_colocation/command.py +++ b/pcs/cli/constraint_colocation/command.py @@ -94,7 +94,7 @@ def remove(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # deprecated since pcs-0.11.7 deprecation_warning( "Removing colocation constraint with interchanged source " - "resource id and targert resource id. This behavior is " + "resource id and target resource id. This behavior is " "deprecated and will be removed." ) constraint_ids_to_remove.append( diff --git a/pcs/cli/dr.py b/pcs/cli/dr.py index 834300acb..ffbce97f3 100644 --- a/pcs/cli/dr.py +++ b/pcs/cli/dr.py @@ -41,8 +41,7 @@ def config( dto.PayloadConversionError, ) as e: raise error( - "Unable to communicate with pcsd, received response:\n" - f"{config_raw}" + f"Unable to communicate with pcsd, received response:\n{config_raw}" ) from e lines = ["Local site:"] diff --git a/pcs/cli/node/__init__.py b/pcs/cli/node/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pcs/cli/node/command.py b/pcs/cli/node/command.py new file mode 100644 index 000000000..63879527c --- /dev/null +++ b/pcs/cli/node/command.py @@ -0,0 +1,120 @@ +import json +from typing import Any, Callable + +from pcs.cli.cluster_property.output import PropertyConfigurationFacade +from pcs.cli.common.errors import CmdLineInputError +from pcs.cli.common.output import lines_to_str +from pcs.cli.common.parse_args import ( + OUTPUT_FORMAT_OPTION, + OUTPUT_FORMAT_VALUE_CMD, + OUTPUT_FORMAT_VALUE_JSON, + OUTPUT_FORMAT_VALUE_TEXT, + Argv, + InputModifiers, +) +from pcs.common.interface.dto import to_dict +from pcs.common.pacemaker.node import CibNodeListDto +from pcs.utils import print_warning_if_utilization_attrs_has_no_effect + +from .output import ( + config_dto_to_attribute_cmd, + config_dto_to_attribute_lines, + config_dto_to_utilization_cmd, + config_dto_to_utilization_lines, + filter_nodes_by_node_name, + filter_nodes_nvpairs_by_name, +) + + +def _node_output_cmd( + lib: Any, + argv: Argv, + modifiers: InputModifiers, + supported_options: list[str], + to_cmd: Callable[[CibNodeListDto], list[str]], + to_lines: Callable[[CibNodeListDto], list[str]], + utilization_warning: bool = False, +) -> None: + """ + Options: + * -f - CIB file + * --name - specify attribute name for output filter + * --output-format - supported formats: text, cmd, json + """ + modifiers.ensure_only_supported( + *supported_options, output_format_supported=True + ) + if len(argv) > 1 or modifiers.is_specified("--force"): + raise CmdLineInputError() + output_format = modifiers.get_output_format() + if output_format != OUTPUT_FORMAT_VALUE_TEXT and ( + argv or modifiers.is_specified("--name") + ): + raise CmdLineInputError( + f"filtering is not supported with {OUTPUT_FORMAT_OPTION}=" + f"{OUTPUT_FORMAT_VALUE_CMD}|{OUTPUT_FORMAT_VALUE_JSON}" + ) + config_dto = lib.node.get_config_dto() + if output_format == OUTPUT_FORMAT_VALUE_CMD: + output = ";\n".join(to_cmd(config_dto)) + elif output_format == OUTPUT_FORMAT_VALUE_JSON: + output = json.dumps(to_dict(config_dto)) + else: + if argv: + node_name = argv[0] + config_dto = filter_nodes_by_node_name(config_dto, node_name) + if not config_dto.nodes: + raise CmdLineInputError(f"Unable to find a node: {node_name}") + if modifiers.is_specified("--name"): + config_dto = filter_nodes_nvpairs_by_name( + config_dto, str(modifiers.get("--name")) + ) + if utilization_warning: + print_warning_if_utilization_attrs_has_no_effect( + PropertyConfigurationFacade.from_properties_dtos( + lib.cluster_property.get_properties(), + lib.cluster_property.get_properties_metadata(), + ) + ) + output = lines_to_str(to_lines(config_dto)) + if output: + print(output) + + +def node_attribute_output_cmd( + lib: Any, argv: Argv, modifiers: InputModifiers +) -> None: + """ + Options: + * -f - CIB file + * --name - specify attribute name for output filter + * --output-format - supported formats: text, cmd, json + """ + _node_output_cmd( + lib, + argv, + modifiers, + ["-f", "--force", "--name"], + config_dto_to_attribute_cmd, + config_dto_to_attribute_lines, + ) + + +def node_utilization_output_cmd( + lib: Any, argv: Argv, modifiers: InputModifiers +) -> None: + """ + Options: + * -f - CIB file + * --name - specify attribute name for output filter + * --output-format - supported formats: text, cmd, json + """ + _node_output_cmd( + lib, + argv, + modifiers, + ["-f", "--name"], + config_dto_to_utilization_cmd, + config_dto_to_utilization_lines, + utilization_warning=True, + ) diff --git a/pcs/cli/node/output.py b/pcs/cli/node/output.py new file mode 100644 index 000000000..ec93a30e9 --- /dev/null +++ b/pcs/cli/node/output.py @@ -0,0 +1,115 @@ +import shlex +from dataclasses import replace +from typing import Optional, Sequence + +from pcs.cli.common.output import INDENT_STEP, pairs_to_cmd +from pcs.cli.nvset import filter_nvpairs_by_names, nvset_dto_to_lines +from pcs.common.pacemaker.node import CibNodeDto, CibNodeListDto +from pcs.common.pacemaker.nvset import CibNvsetDto +from pcs.common.str_tools import indent + + +def _description_to_lines(desc: Optional[str]) -> list[str]: + return [f"Description: {desc}"] if desc else [] + + +def _nvsets_to_lines(label: str, nvsets: Sequence[CibNvsetDto]) -> list[str]: + if nvsets and nvsets[0].nvpairs: + return nvset_dto_to_lines(nvset=nvsets[0], nvset_label=label) + return [] + + +def _node_dto_to_nvset_lines( + node_dto: CibNodeDto, label: str, nvsets: Sequence[CibNvsetDto] +) -> list[str]: + nvsets_lines = _nvsets_to_lines(label, nvsets) + if not nvsets_lines: + return [] + lines = _description_to_lines(node_dto.description) + lines.extend(nvsets_lines) + return [f"Node: {node_dto.uname}"] + indent(lines, indent_step=INDENT_STEP) + + +def config_dto_to_utilization_lines(config_dto: CibNodeListDto) -> list[str]: + result = [] + for node_dto in config_dto.nodes: + result.extend( + _node_dto_to_nvset_lines( + node_dto, "Utilization", node_dto.utilization + ) + ) + return result + + +def config_dto_to_attribute_lines(config_dto: CibNodeListDto) -> list[str]: + result = [] + for node_dto in config_dto.nodes: + result.extend( + _node_dto_to_nvset_lines( + node_dto, "Attributes", node_dto.instance_attributes + ) + ) + return result + + +def _nvsets_to_cmd( + nvset_cmd: str, node_name: str, nvsets: Sequence[CibNvsetDto] +) -> list[str]: + if nvsets and nvsets[0].nvpairs: + node = shlex.quote(node_name) + options = pairs_to_cmd( + (nvpair.name, nvpair.value) for nvpair in nvsets[0].nvpairs + ) + return [f"pcs -- node {nvset_cmd} {node} {options}"] + return [] + + +def config_dto_to_attribute_cmd(config_dto: CibNodeListDto) -> list[str]: + commands = [] + for node_dto in config_dto.nodes: + commands.extend( + _nvsets_to_cmd( + "attribute", node_dto.uname, node_dto.instance_attributes + ) + ) + return commands + + +def config_dto_to_utilization_cmd(config_dto: CibNodeListDto) -> list[str]: + commands = [] + for node_dto in config_dto.nodes: + commands.extend( + _nvsets_to_cmd("utilization", node_dto.uname, node_dto.utilization) + ) + return commands + + +def filter_nodes_by_node_name( + config_dto: CibNodeListDto, node_name: str +) -> CibNodeListDto: + return CibNodeListDto( + nodes=[ + node_dto + for node_dto in config_dto.nodes + if node_dto.uname == node_name + ] + ) + + +def filter_nodes_nvpairs_by_name( + config_dto: CibNodeListDto, name: str +) -> CibNodeListDto: + return CibNodeListDto( + [ + replace( + node_dto, + instance_attributes=filter_nvpairs_by_names( + node_dto.instance_attributes, [name] + ), + utilization=filter_nvpairs_by_names( + node_dto.utilization, [name] + ), + ) + for node_dto in config_dto.nodes + ] + ) diff --git a/pcs/cli/nvset.py b/pcs/cli/nvset.py index 3ae9a079f..33b9a3d70 100644 --- a/pcs/cli/nvset.py +++ b/pcs/cli/nvset.py @@ -1,3 +1,4 @@ +from dataclasses import replace from typing import ( Iterable, List, @@ -14,7 +15,7 @@ format_optional, indent, ) -from pcs.common.types import CibRuleInEffectStatus +from pcs.common.types import CibRuleInEffectStatus, StringSequence def filter_out_expired_nvset( @@ -28,6 +29,22 @@ def filter_out_expired_nvset( ] +def filter_nvpairs_by_names( + nvsets: Iterable[CibNvsetDto], nvpair_names: StringSequence +) -> list[CibNvsetDto]: + return [ + replace( + nvset_dto, + nvpairs=[ + nvpair_dto + for nvpair_dto in nvset_dto.nvpairs + if nvpair_dto.name in nvpair_names + ], + ) + for nvset_dto in nvsets + ] + + def nvset_dto_list_to_lines( nvset_dto_list: Iterable[CibNvsetDto], nvset_label: str, diff --git a/pcs/cli/query/resource.py b/pcs/cli/query/resource.py index cee13d194..bbba12de3 100644 --- a/pcs/cli/query/resource.py +++ b/pcs/cli/query/resource.py @@ -355,9 +355,7 @@ def is_state(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: sections = group_by_keywords( argv, - set( - ["on-node", "members", "instances"], - ), + {"on-node", "members", "instances"}, implicit_first_keyword="state", ) sections.ensure_unique_keywords() diff --git a/pcs/cli/reports/messages.py b/pcs/cli/reports/messages.py index e4efc2949..2b587581b 100644 --- a/pcs/cli/reports/messages.py +++ b/pcs/cli/reports/messages.py @@ -266,15 +266,20 @@ def message(self) -> str: ) +class UseCommandNodeRemoveRemote(CliReportMessageCustom): + _obj: messages.UseCommandNodeRemoveRemote + + @property + def message(self) -> str: + return self._obj.message + ", use 'pcs cluster node remove-remote'" + + class UseCommandNodeRemoveGuest(CliReportMessageCustom): _obj: messages.UseCommandNodeRemoveGuest @property def message(self) -> str: - return ( - "this command is not sufficient for removing a guest node, use" - " 'pcs cluster node remove-guest'" - ) + return self._obj.message + ", use 'pcs cluster node remove-guest'" class UseCommandNodeAddGuest(CliReportMessageCustom): @@ -299,6 +304,19 @@ def message(self) -> str: ) +class UseCommandRemoveAndAddGuestNode(CliReportMessageCustom): + _obj: messages.UseCommandRemoveAndAddGuestNode + + @property + def message(self) -> str: + return ( + "Changing connection parameters of an existing guest node is not " + "sufficient for connecting to a different guest node, remove the " + "existing guest node with 'pcs cluster node remove-guest' and add " + "a new one with 'pcs cluster node add-guest'" + ) + + class CorosyncNodeConflictCheckSkipped(CliReportMessageCustom): _obj: messages.CorosyncNodeConflictCheckSkipped @@ -637,7 +655,7 @@ def _create_report_msg_map() -> Dict[str, type]: for report_msg_cls in get_all_subclasses(CliReportMessageCustom): # pylint: disable=protected-access code = ( - get_type_hints(report_msg_cls) + get_type_hints(report_msg_cls) # noqa: SLF001 .get("_obj", item.ReportItemMessage) ._code ) diff --git a/pcs/cli/reports/preprocessor.py b/pcs/cli/reports/preprocessor.py index 71365d912..18d200a5e 100644 --- a/pcs/cli/reports/preprocessor.py +++ b/pcs/cli/reports/preprocessor.py @@ -25,7 +25,7 @@ def get_duplicate_constraint_exists_preprocessor( ) -> ReportItemPreprocessor: constraints_dto: Optional[CibConstraintsDto] = None - def _report_item_preprocessor( + def _report_item_preprocessor( # noqa: PLR0912 report_item: reports.ReportItem, ) -> Optional[reports.ReportItem]: """ diff --git a/pcs/cli/resource/command.py b/pcs/cli/resource/command.py new file mode 100644 index 000000000..a2355b8a9 --- /dev/null +++ b/pcs/cli/resource/command.py @@ -0,0 +1,102 @@ +import json +from typing import Any + +from pcs.cli.common.errors import CmdLineInputError +from pcs.cli.common.output import ( + format_cmd_list, + lines_to_str, + smart_wrap_text, +) +from pcs.cli.common.parse_args import ( + OUTPUT_FORMAT_VALUE_CMD, + OUTPUT_FORMAT_VALUE_JSON, + Argv, + InputModifiers, + KeyValueParser, + wait_to_timeout, +) +from pcs.cli.resource.common import ( + check_is_not_stonith, + get_resource_status_msg, +) +from pcs.cli.resource.output import ( + ResourcesConfigurationFacade, + resources_to_cmd, + resources_to_text, +) +from pcs.common import reports +from pcs.common.interface import dto +from pcs.common.pacemaker.resource.list import CibResourcesDto + + +def config(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: + output = config_common(lib, argv, modifiers, stonith=False) + if output: + print(output) + + +def config_common( + lib: Any, argv: Argv, modifiers: InputModifiers, stonith: bool +) -> str: + """ + Also used by stonith commands. + + Options: + * -f - CIB file + * --output-format - supported formats: text, cmd, json + """ + modifiers.ensure_only_supported("-f", output_format_supported=True) + resources_facade = ( + ResourcesConfigurationFacade.from_resources_dto( + lib.resource.get_configured_resources() + ) + .filter_stonith(stonith) + .filter_resources(argv) + ) + output_format = modifiers.get_output_format() + if output_format == OUTPUT_FORMAT_VALUE_CMD: + output = format_cmd_list( + [" \\\n".join(cmd) for cmd in resources_to_cmd(resources_facade)] + ) + elif output_format == OUTPUT_FORMAT_VALUE_JSON: + output = json.dumps( + dto.to_dict( + CibResourcesDto( + primitives=resources_facade.primitives, + clones=resources_facade.clones, + groups=resources_facade.groups, + bundles=resources_facade.bundles, + ) + ) + ) + else: + output = lines_to_str( + smart_wrap_text(resources_to_text(resources_facade)) + ) + return output + + +def meta(lib: Any, argv: list[str], modifiers: InputModifiers) -> None: + """ + Options: + * --force - override editing connection attributes for guest nodes + * --wait - wait for cluster to reach steady state + * -f + """ + modifiers.ensure_only_supported("-f", "--force", "--wait") + modifiers.ensure_not_mutually_exclusive("-f", "--wait") + wait_timeout = wait_to_timeout(modifiers.get("--wait")) + force_flags = [] + if modifiers.get("--force"): + force_flags.append(reports.codes.FORCE) + if not argv: + raise CmdLineInputError() + resource_id = argv.pop(0) + check_is_not_stonith(lib, [resource_id], "pcs stonith meta") + meta_attrs_dict = KeyValueParser(argv).get_unique() + + lib.resource.update_meta(resource_id, meta_attrs_dict, force_flags) + + if wait_timeout >= 0: + lib.cluster.wait_for_pcmk_idle(wait_timeout) + print(get_resource_status_msg(lib, resource_id)) diff --git a/pcs/cli/resource/common.py b/pcs/cli/resource/common.py new file mode 100644 index 000000000..3748491ad --- /dev/null +++ b/pcs/cli/resource/common.py @@ -0,0 +1,122 @@ +from collections import defaultdict +from typing import ( + Any, + Optional, + Sequence, + Union, +) + +from pcs.cli.reports.output import ( + deprecation_warning, +) +from pcs.common import ( + const, + reports, +) +from pcs.common.status_dto import ( + AnyResourceStatusDto, + BundleStatusDto, + CloneStatusDto, + GroupStatusDto, + PrimitiveStatusDto, +) +from pcs.common.str_tools import ( + format_list, + format_optional, + format_plural, +) + +RESOURCE_NOT_RUNNING = "Resource '{resource_id}' is not running on any nodes" + + +def check_is_not_stonith( + lib: Any, resource_id_list: list[str], cmd_to_use: Optional[str] = None +) -> None: + if lib.resource.is_any_stonith(resource_id_list): + deprecation_warning( + reports.messages.ResourceStonithCommandsMismatch( + "stonith resources" + ).message + + format_optional(cmd_to_use, " Please use '{}' instead.") + ) + + +def _get_primitive_instance_list_dto( + resource_dto: Union[AnyResourceStatusDto], +) -> Sequence[PrimitiveStatusDto]: + """ + Return a list of primitive instances from any resource status DTO. + """ + if isinstance(resource_dto, GroupStatusDto): + return resource_dto.members + if isinstance(resource_dto, CloneStatusDto): + if isinstance(resource_dto.instances[0], GroupStatusDto): + instance_list: list[PrimitiveStatusDto] = [] + for group_dto in resource_dto.instances: + # There is a check for the type in the if above + instance_list.extend(group_dto.members) # type: ignore + return instance_list + # There is a check for the type in the if above + return resource_dto.instances # type: ignore + if isinstance(resource_dto, BundleStatusDto): + return [replica_dto.container for replica_dto in resource_dto.replicas] + return [resource_dto] + + +def get_resource_status_msg(lib: Any, resource_id: str) -> str: + """ + Get where resources are running, typically used after waiting for cluster + is finished. Returns a text string similar to utils.resource_running_on. + Examples: + Resource 'r1' is not running on any nodes + Resource 'r2-clone' is promoted on node 'n1'; unpromoted on nodes 'n2', + 'n3' + """ + resource_dto = None + for resource in lib.status.resources_status().resources: + if resource.resource_id == resource_id: + resource_dto = resource + if resource_dto is None: + # Resource is configured but Pacemaker is ignoring it + return RESOURCE_NOT_RUNNING.format(resource_id=resource_id) + + # Using set in case more instances are running on the same node + role_and_location: dict[const.PcmkStatusRoleType, set[str]] = defaultdict( + set + ) + instance_list_dto = _get_primitive_instance_list_dto(resource_dto) + for instance_dto in instance_list_dto: + role_and_location[instance_dto.role].update(instance_dto.node_names) + + # The old function (utils.resource_running_on) this is replicating only + # supported states Started, Promoted and Unpromoted. Therefore we have to + # filter out only these states since others were ignored previously. + role_and_location = { + role: loc + for role, loc in role_and_location.items() + if role + in [ + const.PCMK_STATUS_ROLE_STARTED, + const.PCMK_STATUS_ROLE_PROMOTED, + const.PCMK_STATUS_ROLE_UNPROMOTED, + ] + } + + if not role_and_location: + return RESOURCE_NOT_RUNNING.format(resource_id=resource_id) + + state_parts = [] + for state_name, node_list in role_and_location.items(): + state_parts.append( + "{state_name} on {node_pl} {node_list}".format( + state_name=( + state_name.lower() + if state_name != const.PCMK_STATUS_ROLE_STARTED + else "running" + ), + node_pl=format_plural(depends_on=node_list, singular="node"), + node_list=format_list(node_list), + ) + ) + state_info = "; ".join(state_parts) + return f"Resource '{resource_id}' is {state_info}" diff --git a/pcs/cli/resource/output.py b/pcs/cli/resource/output.py index 9bfffe4ef..d3e1c4031 100644 --- a/pcs/cli/resource/output.py +++ b/pcs/cli/resource/output.py @@ -1,11 +1,13 @@ import shlex from collections import defaultdict -from typing import ( +from collections.abc import ( Container, + Sequence, +) +from typing import ( Dict, List, Optional, - Sequence, Tuple, Union, ) @@ -17,6 +19,7 @@ format_wrap_for_terminal, options_to_cmd, pairs_to_cmd, + smart_wrap_text, ) from pcs.cli.nvset import nvset_dto_to_lines from pcs.cli.reports.output import warn @@ -111,7 +114,7 @@ def _resource_operation_to_str( ] + indent(lines, indent_step=INDENT_STEP) -def resource_agent_parameter_metadata_to_text( +def resource_agent_parameter_metadata_to_text( # noqa: PLR0912 parameter: resource_agent.dto.ResourceAgentParameterDto, ) -> list[str]: # pylint: disable=too-many-branches @@ -189,11 +192,17 @@ def resource_agent_metadata_to_text( if metadata.longdesc: output.append("") - output.extend( - format_wrap_for_terminal( - metadata.longdesc.replace("\n", " "), subsequent_indent=0 + # Some agents provide longdesc as a single long line. Others provide + # formatted text with pagarpahs, indentation, lists, etc. Wrap only + # one-line longdesc to preserve such formatting. Agent authors are + # expected to wrap formatted longdesc appropriately. We cannot fix just + # some wrapping while keeping other formatting in place. + if "\n" in metadata.longdesc: + output.extend(metadata.longdesc.splitlines()) + else: + output.extend( + format_wrap_for_terminal(metadata.longdesc, subsequent_indent=0) ) - ) params = [] for param in metadata.parameters: @@ -207,7 +216,7 @@ def resource_agent_metadata_to_text( output.append("Stonith options:") else: output.append("Resource options:") - output.extend(indent(params, indent_step=INDENT_STEP)) + output.extend(smart_wrap_text(indent(params, indent_step=INDENT_STEP))) operations = [] for operation in default_operations: @@ -216,7 +225,9 @@ def resource_agent_metadata_to_text( if operations: output.append("") output.append("Default operations:") - output.extend(indent(operations, indent_step=INDENT_STEP)) + output.extend( + smart_wrap_text(indent(operations, indent_step=INDENT_STEP)) + ) return output @@ -284,7 +295,9 @@ def get_primitive_dto( def get_group_dto(self, obj_id: str) -> Optional[CibResourceGroupDto]: return self._groups_map.get(obj_id) - def _get_any_resource_dto(self, obj_id: str) -> Optional[ + def _get_any_resource_dto( + self, obj_id: str + ) -> Optional[ Union[ CibResourcePrimitiveDto, CibResourceGroupDto, @@ -628,11 +641,11 @@ def _resource_bundle_storage_to_text( ) -> List[str]: if not storage_mappings: return [] - output = [] - for storage_mapping in storage_mappings: - output.append( - " ".join(_resource_bundle_storage_mapping_to_str(storage_mapping)) - ) + output = [ + " ".join(_resource_bundle_storage_mapping_to_str(storage_mapping)) + for storage_mapping in storage_mappings + ] + return ["Storage Mapping:"] + indent(output, indent_step=INDENT_STEP) @@ -719,14 +732,13 @@ def _resource_operation_to_cmd( ) -> List[str]: if not operations: return [] - cmd = [] - for op in operations: - cmd.append( - "{name} {options}".format( - name=op.name, - options=pairs_to_cmd(_resource_operation_to_pairs(op)), - ) + cmd = [ + "{name} {options}".format( + name=op.name, + options=pairs_to_cmd(_resource_operation_to_pairs(op)), ) + for op in operations + ] return ["op"] + indent(cmd, indent_step=INDENT_STEP) diff --git a/pcs/cli/resource/parse_args.py b/pcs/cli/resource/parse_args.py index 88f2b2852..41b914ce9 100644 --- a/pcs/cli/resource/parse_args.py +++ b/pcs/cli/resource/parse_args.py @@ -74,10 +74,10 @@ class AddRemoveOptions: def parse_primitive(arg_list: Argv) -> PrimitiveOptions: groups = group_by_keywords( - arg_list, set(["op", "meta"]), implicit_first_keyword="instance" + arg_list, {"op", "meta"}, implicit_first_keyword="instance" ) - parts = PrimitiveOptions( + return PrimitiveOptions( instance_attrs=KeyValueParser( groups.get_args_flat("instance") ).get_unique(), @@ -88,12 +88,10 @@ def parse_primitive(arg_list: Argv) -> PrimitiveOptions: ], ) - return parts - def parse_clone(arg_list: Argv, promotable: bool = False) -> CloneOptions: clone_id = None - allowed_keywords = set(["op", "meta"]) + allowed_keywords = {"op", "meta"} if ( arg_list and arg_list[0] not in allowed_keywords @@ -128,20 +126,20 @@ def parse_clone(arg_list: Argv, promotable: bool = False) -> CloneOptions: return CloneOptions(clone_id=clone_id, meta_attrs=meta) -def parse_create_new(arg_list: Argv) -> ComplexResourceOptions: +def parse_create_new(arg_list: Argv) -> ComplexResourceOptions: # noqa: PLR0912 # pylint: disable=too-many-branches # pylint: disable=too-many-locals try: top_groups = group_by_keywords( arg_list, - set(["clone", "promotable", "bundle", "group"]), + {"clone", "promotable", "bundle", "group"}, implicit_first_keyword="primitive", ) top_groups.ensure_unique_keywords() primitive_groups = group_by_keywords( top_groups.get_args_flat("primitive"), - set(["op", "meta"]), + {"op", "meta"}, implicit_first_keyword="instance", ) primitive_options = PrimitiveOptions( @@ -163,7 +161,7 @@ def parse_create_new(arg_list: Argv) -> ComplexResourceOptions: if top_groups.has_keyword("group"): group_groups = group_by_keywords( top_groups.get_args_flat("group"), - set(["before", "after", "op", "meta"]), + {"before", "after", "op", "meta"}, implicit_first_keyword="group_id", ) if group_groups.has_keyword("meta"): @@ -203,7 +201,7 @@ def parse_create_new(arg_list: Argv) -> ComplexResourceOptions: continue clone_groups = group_by_keywords( top_groups.get_args_flat(clone_type), - set(["op", "meta"]), + {"op", "meta"}, implicit_first_keyword="options", ) clone_id = None @@ -232,7 +230,7 @@ def parse_create_new(arg_list: Argv) -> ComplexResourceOptions: if top_groups.has_keyword("bundle"): bundle_groups = group_by_keywords( top_groups.get_args_flat("bundle"), - set(["op", "meta"]), + {"op", "meta"}, implicit_first_keyword="options", ) if bundle_groups.has_keyword("meta"): @@ -276,7 +274,7 @@ def parse_create_new(arg_list: Argv) -> ComplexResourceOptions: # deprecated since 0.11.6 -def parse_create_old( +def parse_create_old( # noqa: PLR0912, PLR0915 arg_list: Argv, modifiers: InputModifiers ) -> ComplexResourceOptions: # pylint: disable=too-many-branches @@ -285,13 +283,13 @@ def parse_create_old( try: top_groups = group_by_keywords( arg_list, - set(["clone", "promotable", "bundle"]), + {"clone", "promotable", "bundle"}, implicit_first_keyword="primitive", ) primitive_groups = group_by_keywords( top_groups.get_args_flat("primitive"), - set(["op", "meta"]), + {"op", "meta"}, implicit_first_keyword="instance", ) primitive_instance_attrs = primitive_groups.get_args_flat("instance") @@ -335,7 +333,7 @@ def parse_create_old( continue clone_groups = group_by_keywords( top_groups.get_args_flat(clone_type), - set(["op", "meta"]), + {"op", "meta"}, implicit_first_keyword="options", ) clone_id = None @@ -375,7 +373,7 @@ def parse_create_old( if top_groups.has_keyword("bundle"): bundle_groups = group_by_keywords( top_groups.get_args_flat("bundle"), - set(["op", "meta"]), + {"op", "meta"}, implicit_first_keyword="options", ) if bundle_groups.has_keyword("meta"): @@ -444,9 +442,8 @@ def _parse_bundle_groups(arg_list: Argv) -> ArgsByKeywords: for repeated_section in groups.get_args_groups(keyword): if not repeated_section: raise CmdLineInputError(f"No {keyword} options specified") - else: - if not groups.get_args_flat(keyword): - raise CmdLineInputError(f"No {keyword} options specified") + elif not groups.get_args_flat(keyword): + raise CmdLineInputError(f"No {keyword} options specified") return groups diff --git a/pcs/cli/resource/relations.py b/pcs/cli/resource/relations.py index 261148b36..218f5a332 100644 --- a/pcs/cli/resource/relations.py +++ b/pcs/cli/resource/relations.py @@ -200,14 +200,13 @@ def _order_metadata_to_str(metadata: Mapping[str, Any]) -> list[str]: def _order_set_metadata_to_str(metadata: Mapping[str, Any]) -> list[str]: - result = [] - for res_set in metadata["sets"]: - result.append( - " set {resources}{options}".format( - resources=" ".join(res_set["members"]), - options=_resource_set_options_to_str(res_set["metadata"]), - ) + result = [ + " set {resources}{options}".format( + resources=" ".join(res_set["members"]), + options=_resource_set_options_to_str(res_set["metadata"]), ) + for res_set in metadata["sets"] + ] return _order_common_metadata_to_str(metadata) + result diff --git a/pcs/cli/routing/alert.py b/pcs/cli/routing/alert.py index f93f3d72b..f6ca71d2e 100644 --- a/pcs/cli/routing/alert.py +++ b/pcs/cli/routing/alert.py @@ -2,6 +2,7 @@ alert, usage, ) +from pcs.cli.alert import command as alert_command from pcs.cli.common.routing import create_router alert_cmd = create_router( @@ -11,7 +12,7 @@ "update": alert.alert_update, "delete": alert.alert_remove, "remove": alert.alert_remove, - "config": alert.print_alert_config, + "config": alert_command.alert_config, # TODO remove, deprecated command # replaced with 'config' "show": alert.print_alert_show, diff --git a/pcs/cli/routing/booth.py b/pcs/cli/routing/booth.py index 811809f14..c2aff5231 100644 --- a/pcs/cli/routing/booth.py +++ b/pcs/cli/routing/booth.py @@ -4,10 +4,6 @@ ) from pcs.cli.booth import command from pcs.cli.common.routing import create_router -from pcs.resource import ( - resource_remove, - resource_restart, -) mapping = { "help": lambda lib, argv, modifiers: print(usage.booth(argv)), @@ -18,19 +14,20 @@ { "help": lambda lib, argv, modifiers: print(usage.booth(["ticket"])), "add": command.config_ticket_add, + "cleanup": command.ticket_cleanup, "delete": command.config_ticket_remove, - "remove": command.config_ticket_remove, "grant": command.ticket_grant, + "remove": command.config_ticket_remove, "revoke": command.ticket_revoke, + "standby": command.ticket_standby, + "unstandby": command.ticket_unstandby, }, ["booth", "ticket"], ), "create": command.create_in_cluster, - # ignoring mypy errors, these functions need to be fixed, they are passing - # a function to pcs.lib - "delete": command.get_remove_from_cluster(resource_remove), # type:ignore - "remove": command.get_remove_from_cluster(resource_remove), # type:ignore - "restart": command.get_restart(resource_restart), # type:ignore + "delete": command.remove_from_cluster, + "remove": command.remove_from_cluster, + "restart": command.restart, "sync": command.sync, "pull": command.pull, "enable": command.enable, diff --git a/pcs/cli/routing/cluster.py b/pcs/cli/routing/cluster.py index 31bf0c668..b2d14692c 100644 --- a/pcs/cli/routing/cluster.py +++ b/pcs/cli/routing/cluster.py @@ -4,7 +4,6 @@ from pcs import ( cluster, pcsd, - resource, status, usage, ) @@ -104,16 +103,12 @@ def pcsd_status(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: "clear": cluster_command.node_clear, "delete": cluster.node_remove, "delete-guest": cluster_command.node_remove_guest, - # ignoring mypy errors, these functions need to be fixed, they - # are passing a function to pcs.lib - "delete-remote": cluster_command.create_node_remove_remote( - resource.resource_remove - ), # type:ignore + "delete-remote": cluster_command.node_remove_remote, "remove": cluster.node_remove, "remove-guest": cluster_command.node_remove_guest, - "remove-remote": cluster_command.create_node_remove_remote( - resource.resource_remove - ), # type:ignore + "remove-remote": cluster_command.node_remove_remote, + "rename-cib": cluster_command.node_rename_cib, + "rename-corosync": cluster_command.node_rename_corosync, }, ["cluster", "node"], ), @@ -124,6 +119,7 @@ def pcsd_status(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: "verify": cluster.cluster_verify, "report": cluster.cluster_report, "remove_nodes_from_cib": cluster.remove_nodes_from_cib, + "rename": cluster_command.cluster_rename, }, ["cluster"], ) diff --git a/pcs/cli/routing/node.py b/pcs/cli/routing/node.py index 4b2320eb9..cc1ee0331 100644 --- a/pcs/cli/routing/node.py +++ b/pcs/cli/routing/node.py @@ -1,10 +1,36 @@ from functools import partial +from typing import Any from pcs import ( node, usage, ) +from pcs.cli.common.parse_args import Argv, InputModifiers from pcs.cli.common.routing import create_router +from pcs.cli.node import command as node_command + + +def _node_attribute_cmd( + lib: Any, argv: Argv, modifiers: InputModifiers +) -> None: + if len(argv) > 1: + # set command + node.node_attribute_cmd(lib, argv, modifiers) + else: + # config command + node_command.node_attribute_output_cmd(lib, argv, modifiers) + + +def _node_utilization_cmd( + lib: Any, argv: Argv, modifiers: InputModifiers +) -> None: + if len(argv) > 1: + # set command + node.node_utilization_cmd(lib, argv, modifiers) + else: + # config command + node_command.node_utilization_output_cmd(lib, argv, modifiers) + node_cmd = create_router( { @@ -13,8 +39,8 @@ "unmaintenance": partial(node.node_maintenance_cmd, enable=False), "standby": partial(node.node_standby_cmd, enable=True), "unstandby": partial(node.node_standby_cmd, enable=False), - "attribute": node.node_attribute_cmd, - "utilization": node.node_utilization_cmd, + "attribute": _node_attribute_cmd, + "utilization": _node_utilization_cmd, # pcs-to-pcsd use only "pacemaker-status": node.node_pacemaker_status, }, diff --git a/pcs/cli/routing/prop.py b/pcs/cli/routing/prop.py index 726b616ea..6f1f669b6 100644 --- a/pcs/cli/routing/prop.py +++ b/pcs/cli/routing/prop.py @@ -4,7 +4,9 @@ property_cmd = create_router( { - "help": lambda _lib, _argv, _modifiers: print(usage.property(_argv)), + "help": lambda _lib, _argv, _modifiers: print( + usage.property_usage(_argv) + ), "set": cluster_property.set_property, "unset": cluster_property.unset_property, # TODO remove, deprecated command diff --git a/pcs/cli/routing/resource.py b/pcs/cli/routing/resource.py index b999a86ff..eae794e46 100644 --- a/pcs/cli/routing/resource.py +++ b/pcs/cli/routing/resource.py @@ -1,5 +1,6 @@ from functools import partial +import pcs.cli.resource.command as resource_cli from pcs import ( resource, usage, @@ -26,14 +27,14 @@ "providers": resource.resource_providers, "agents": resource.resource_agents, "update": resource.update_cmd, - "meta": resource.meta_cmd, + "meta": resource_cli.meta, "delete": resource.resource_remove_cmd, "remove": resource.resource_remove_cmd, # TODO remove, deprecated command # replaced with 'resource status' and 'resource config' "show": resource.resource_show, "status": resource.resource_status, - "config": resource.config, + "config": resource_cli.config, "group": create_router( { "add": resource.resource_group_add_cmd, @@ -50,7 +51,7 @@ "enable": resource.resource_enable_cmd, "disable": resource.resource_disable_cmd, "safe-disable": resource.resource_safe_disable_cmd, - "restart": resource.resource_restart, + "restart": resource.resource_restart_cmd, "debug-start": partial( resource.resource_force_action, action="debug-start" ), diff --git a/pcs/cli/routing/stonith.py b/pcs/cli/routing/stonith.py index da737d2c3..753c99991 100644 --- a/pcs/cli/routing/stonith.py +++ b/pcs/cli/routing/stonith.py @@ -1,3 +1,5 @@ +import pcs.cli.stonith.command as stonith_cli +import pcs.cli.stonith.levels.command as levels_cli from pcs import ( resource, stonith, @@ -15,7 +17,7 @@ "help": lambda lib, argv, modifiers: print(usage.stonith(argv)), "list": stonith.stonith_list_available, "describe": stonith.stonith_list_options, - "config": stonith.config_cmd, + "config": stonith_cli.config, "create": stonith.stonith_create, "update": stonith.update_cmd, "update-scsi-devices": stonith.stonith_update_scsi_devices, @@ -25,7 +27,7 @@ # replaced with 'stonith status' and 'stonith config' "show": stonith.stonith_show_cmd, "status": stonith.stonith_status_cmd, - "meta": stonith.meta_cmd, + "meta": stonith_cli.meta, "op": create_router( { "defaults": resource_op_defaults_cmd( @@ -42,7 +44,7 @@ { "add": stonith.stonith_level_add_cmd, "clear": stonith.stonith_level_clear_cmd, - "config": stonith.stonith_level_config_cmd, + "config": levels_cli.config, "remove": stonith.stonith_level_remove_cmd, "delete": stonith.stonith_level_remove_cmd, "verify": stonith.stonith_level_verify_cmd, diff --git a/pcs/cli/stonith/__init__.py b/pcs/cli/stonith/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pcs/cli/stonith/command.py b/pcs/cli/stonith/command.py new file mode 100644 index 000000000..0c0ec1f45 --- /dev/null +++ b/pcs/cli/stonith/command.py @@ -0,0 +1,90 @@ +from typing import Any + +from pcs.cli.common.errors import CmdLineInputError +from pcs.cli.common.output import ( + format_cmd_list, + lines_to_str, + smart_wrap_text, +) +from pcs.cli.common.parse_args import ( + OUTPUT_FORMAT_VALUE_CMD, + OUTPUT_FORMAT_VALUE_JSON, + Argv, + InputModifiers, + KeyValueParser, + wait_to_timeout, +) +from pcs.cli.reports.output import warn +from pcs.cli.resource import command as resource_cmd +from pcs.cli.resource.common import get_resource_status_msg +from pcs.cli.stonith.common import check_is_stonith +from pcs.cli.stonith.levels.output import ( + stonith_level_config_to_cmd, + stonith_level_config_to_text, +) +from pcs.common import reports +from pcs.common.str_tools import indent + + +def config(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: + """ + Options: + * --output-format - supported formats: text, cmd, json + * -f CIB file + """ + output_format = modifiers.get_output_format() + output = resource_cmd.config_common(lib, argv, modifiers, stonith=True) + + if output_format == OUTPUT_FORMAT_VALUE_JSON: + # JSON output format does not include fencing levels because it would + # change the current JSON structure and break existing user tooling + warn( + "Fencing levels are not included because this command could only " + "export stonith configuration previously. This cannot be changed " + "to avoid breaking existing tooling. To export fencing levels, run " + "'pcs stonith level config --output-format=json'" + ) + print(output) + return + + fencing_topology_dto = lib.fencing_topology.get_config_dto() + if output_format == OUTPUT_FORMAT_VALUE_CMD: + # we can look at the output of config_common as one command + output = format_cmd_list( + [output, *stonith_level_config_to_cmd(fencing_topology_dto)] + ) + else: + text_output = stonith_level_config_to_text(fencing_topology_dto) + if text_output: + output += "\n\nFencing Levels:\n" + lines_to_str( + smart_wrap_text(indent(text_output)) + ) + + if output: + print(output) + + +def meta(lib: Any, argv: list[str], modifiers: InputModifiers) -> None: + """ + Options: + * --force - override editing connection attributes for guest nodes + * --wait - wait for cluster to reach steady state + * -f + """ + modifiers.ensure_only_supported("-f", "--force", "--wait") + modifiers.ensure_not_mutually_exclusive("-f", "--wait") + wait_timeout = wait_to_timeout(modifiers.get("--wait")) + force_flags = [] + if modifiers.get("--force"): + force_flags.append(reports.codes.FORCE) + if not argv: + raise CmdLineInputError() + resource_id = argv.pop(0) + check_is_stonith(lib, [resource_id], "pcs resource meta") + meta_attrs_dict = KeyValueParser(argv).get_unique() + + lib.resource.update_meta(resource_id, meta_attrs_dict, force_flags) + + if wait_timeout >= 0: + lib.cluster.wait_for_pcmk_idle(wait_timeout) + print(get_resource_status_msg(lib, resource_id)) diff --git a/pcs/cli/stonith/common.py b/pcs/cli/stonith/common.py new file mode 100644 index 000000000..f96f9ec93 --- /dev/null +++ b/pcs/cli/stonith/common.py @@ -0,0 +1,22 @@ +from typing import ( + Any, + Optional, +) + +from pcs.cli.reports.output import deprecation_warning +from pcs.common import reports +from pcs.common.str_tools import format_optional + + +def check_is_stonith( + lib: Any, + resource_id_list: list[str], + cmd_to_use: Optional[str] = None, +) -> None: + if lib.resource.is_any_resource_except_stonith(resource_id_list): + deprecation_warning( + reports.messages.ResourceStonithCommandsMismatch( + "resources" + ).message + + format_optional(cmd_to_use, " Please use '{}' instead.") + ) diff --git a/pcs/cli/stonith/levels/__init__.py b/pcs/cli/stonith/levels/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pcs/cli/stonith/levels/command.py b/pcs/cli/stonith/levels/command.py new file mode 100644 index 000000000..9301415c1 --- /dev/null +++ b/pcs/cli/stonith/levels/command.py @@ -0,0 +1,44 @@ +import json +from typing import Any + +from pcs.cli.common.errors import CmdLineInputError +from pcs.cli.common.output import ( + format_cmd_list, + lines_to_str, +) +from pcs.cli.common.parse_args import ( + OUTPUT_FORMAT_VALUE_CMD, + OUTPUT_FORMAT_VALUE_JSON, + Argv, + InputModifiers, +) +from pcs.cli.stonith.levels import output as levels_output +from pcs.common.interface.dto import to_dict + + +def config(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: + """ + Options: + * --output-format - supported formats: text, cmd, json + * -f - CIB file + """ + modifiers.ensure_only_supported("-f", output_format_supported=True) + output_format = modifiers.get_output_format() + if argv: + raise CmdLineInputError + + fencing_topology_dto = lib.fencing_topology.get_config_dto() + + if output_format == OUTPUT_FORMAT_VALUE_JSON: + output = json.dumps(to_dict(fencing_topology_dto)) + elif output_format == OUTPUT_FORMAT_VALUE_CMD: + output = format_cmd_list( + levels_output.stonith_level_config_to_cmd(fencing_topology_dto) + ) + else: + output = lines_to_str( + levels_output.stonith_level_config_to_text(fencing_topology_dto) + ) + + if output: + print(output) diff --git a/pcs/cli/stonith/levels/output.py b/pcs/cli/stonith/levels/output.py new file mode 100644 index 000000000..0048434dd --- /dev/null +++ b/pcs/cli/stonith/levels/output.py @@ -0,0 +1,111 @@ +from collections.abc import Sequence + +from pcs.common.pacemaker.fencing_topology import ( + CibFencingLevel, + CibFencingLevelAttributeDto, + CibFencingLevelRegexDto, + CibFencingTopologyDto, +) +from pcs.common.str_tools import indent +from pcs.common.types import ( + StringCollection, + StringSequence, +) + + +def _get_targets_with_levels_str( + levels: Sequence[CibFencingLevel], +) -> list[str]: + lines = [] + last_target_value = "" + for level in levels: + if isinstance(level, CibFencingLevelAttributeDto): + target_label = "attribute" + target_value = f"{level.target_attribute}={level.target_value}" + elif isinstance(level, CibFencingLevelRegexDto): + target_label = "regexp" + target_value = level.target_pattern + else: + target_label = "node" + target_value = level.target + if target_value != last_target_value: + lines.append(f"Target ({target_label}): {target_value}") + last_target_value = target_value + lines.extend( + indent( + [ + "Level {level}: {devices}".format( + level=level.index, devices=" ".join(level.devices) + ) + ] + ) + ) + return lines + + +def stonith_level_config_to_text( + fencing_topology: CibFencingTopologyDto, +) -> StringSequence: + target_node_levels = sorted( + fencing_topology.target_node, + key=lambda level: (level.target, level.index), + ) + target_regex_levels = sorted( + fencing_topology.target_regex, + key=lambda level: (level.target_pattern, level.index), + ) + target_attr_levels = sorted( + fencing_topology.target_attribute, + key=lambda level: ( + level.target_value, + level.target_attribute, + level.index, + ), + ) + + return ( + _get_targets_with_levels_str(target_node_levels) + + _get_targets_with_levels_str(target_regex_levels) + + _get_targets_with_levels_str(target_attr_levels) + ) + + +def _get_level_add_cmd( + index: int, + target: str, + device_list: StringCollection, + level_id: str, +) -> str: + devices = " ".join(device_list) + return ( + f"pcs stonith level add --force -- {index} {target} {devices} " + f"id={level_id}" + ) + + +def stonith_level_config_to_cmd( + fencing_topology: CibFencingTopologyDto, +) -> StringSequence: + lines: list[str] = [] + lines.extend( + _get_level_add_cmd(level.index, level.target, level.devices, level.id) + for level in fencing_topology.target_node + ) + for level_regex in fencing_topology.target_regex: + target = f"regexp%{level_regex.target_pattern}" + lines.append( + _get_level_add_cmd( + level_regex.index, target, level_regex.devices, level_regex.id + ) + ) + for level_attr in fencing_topology.target_attribute: + target = ( + f"attrib%{level_attr.target_attribute}={level_attr.target_value}" + ) + lines.append( + _get_level_add_cmd( + level_attr.index, target, level_attr.devices, level_attr.id + ) + ) + + return lines diff --git a/pcs/cli/tag/command.py b/pcs/cli/tag/command.py index de7926e79..d74656f72 100644 --- a/pcs/cli/tag/command.py +++ b/pcs/cli/tag/command.py @@ -6,11 +6,8 @@ InputModifiers, group_by_keywords, ) -from pcs.cli.reports.output import ( - deprecation_warning, - print_to_stderr, -) -from pcs.common.str_tools import indent +from pcs.cli.reports.output import deprecation_warning +from pcs.cli.tag.output import print_config def tag_create(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: @@ -41,17 +38,11 @@ def tag_config(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: """ Options: * -f - CIB file + * --output-format - supported formats: text, cmd, json """ - modifiers.ensure_only_supported("-f") - tag_list = lib.tag.config(argv) - if not tag_list: - print_to_stderr(" No tags defined") - return - lines = [] - for tag in tag_list: - lines.append(tag["tag_id"]) - lines.extend(indent(tag["idref_list"])) - print("\n".join(lines)) + modifiers.ensure_only_supported("-f", "--output-format") + tag_dto = lib.tag.get_config_dto(argv) + print_config(tag_dto, modifiers) def tag_remove(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: diff --git a/pcs/cli/tag/output.py b/pcs/cli/tag/output.py new file mode 100644 index 000000000..cbc7b4801 --- /dev/null +++ b/pcs/cli/tag/output.py @@ -0,0 +1,45 @@ +import json +import shlex + +from pcs.cli.common.output import lines_to_str +from pcs.cli.common.parse_args import ( + OUTPUT_FORMAT_VALUE_CMD, + OUTPUT_FORMAT_VALUE_JSON, + InputModifiers, +) +from pcs.common.interface import dto +from pcs.common.pacemaker.tag import CibTagListDto +from pcs.common.str_tools import indent + + +def tags_to_text(tags_dto: CibTagListDto) -> list[str]: + result = [] + for tag in tags_dto.tags: + result.append(tag.id) + result.extend(indent(list(tag.idref_list))) + return result + + +def tags_to_cmd(tags_dto: CibTagListDto) -> list[str]: + return [ + "pcs -- tag create {tag_id} {idref_list}".format( + tag_id=shlex.quote(tag.id), + idref_list=" ".join(shlex.quote(idref) for idref in tag.idref_list), + ) + for tag in tags_dto.tags + ] + + +def print_config(tags_dto: CibTagListDto, modifiers: InputModifiers) -> None: + output_format = modifiers.get_output_format() + if output_format == OUTPUT_FORMAT_VALUE_JSON: + print(json.dumps(dto.to_dict(tags_dto), indent=2)) + return + + if output_format == OUTPUT_FORMAT_VALUE_CMD: + print(";\n".join(tags_to_cmd(tags_dto))) + return + + result = lines_to_str(tags_to_text(tags_dto)) + if result: + print(result) diff --git a/pcs/cluster.py b/pcs/cluster.py index 070287c1f..247fea571 100644 --- a/pcs/cluster.py +++ b/pcs/cluster.py @@ -1,4 +1,5 @@ # pylint: disable=too-many-lines +import contextlib import datetime import json import math @@ -8,21 +9,11 @@ import tempfile import time import xml.dom.minidom -from typing import ( - Any, - Callable, - Iterable, - Mapping, - Optional, - Union, - cast, -) +import xml.parsers.expat +from typing import Any, Callable, Iterable, Mapping, Optional, Union, cast import pcs.lib.pacemaker.live as lib_pacemaker -from pcs import ( - settings, - utils, -) +from pcs import settings, utils from pcs.cli.common import parse_args from pcs.cli.common.errors import ( ERR_NODE_LIST_AND_ALL_MUTUALLY_EXCLUSIVE, @@ -42,40 +33,20 @@ from pcs.cli.reports.messages import report_item_msg_from_dto from pcs.cli.reports.output import warn from pcs.common import file as pcs_file -from pcs.common import ( - file_type_codes, - reports, -) -from pcs.common.corosync_conf import ( - CorosyncConfDto, - CorosyncNodeDto, -) +from pcs.common import file_type_codes, reports +from pcs.common.corosync_conf import CorosyncConfDto, CorosyncNodeDto from pcs.common.interface import dto -from pcs.common.node_communicator import ( - HostNotFound, - Request, - RequestData, -) -from pcs.common.str_tools import ( - format_list, - indent, -) +from pcs.common.node_communicator import HostNotFound, Request, RequestData +from pcs.common.str_tools import format_list, indent, join_multilines from pcs.common.tools import format_os_error -from pcs.common.types import ( - StringCollection, - StringIterable, -) +from pcs.common.types import StringCollection, StringIterable from pcs.lib import sbd as lib_sbd from pcs.lib.commands.remote_node import _destroy_pcmk_remote_env from pcs.lib.communication.nodes import CheckAuth -from pcs.lib.communication.tools import RunRemotelyBase +from pcs.lib.communication.tools import RunRemotelyBase, run_and_raise from pcs.lib.communication.tools import run as run_com_cmd -from pcs.lib.communication.tools import run_and_raise from pcs.lib.corosync import qdevice_net -from pcs.lib.corosync.live import ( - QuorumStatusException, - QuorumStatusFacade, -) +from pcs.lib.corosync.live import QuorumStatusException, QuorumStatusFacade from pcs.lib.errors import LibraryError from pcs.lib.node import get_existing_nodes_names from pcs.utils import parallel_for_nodes @@ -484,7 +455,7 @@ def stop_cluster_all() -> None: stop_cluster_nodes(all_nodes) -def stop_cluster_nodes(nodes: StringCollection) -> None: +def stop_cluster_nodes(nodes: StringCollection) -> None: # noqa: PLR0912 """ Commandline options: * --force - no error when possible quorum loss @@ -792,7 +763,7 @@ def kill_local_cluster_services() -> tuple[str, int]: return utils.run([settings.killall_exec, "-9"] + all_cluster_daemons) -def cluster_push(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: +def cluster_push(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # noqa: PLR0912, PLR0915 """ Options: * --wait @@ -802,6 +773,25 @@ def cluster_push(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # pylint: disable=too-many-branches # pylint: disable=too-many-locals # pylint: disable=too-many-statements + + def get_details_from_crm_verify(): + # get a new runner to run crm_verify command and pass the CIB filename + # into it so that the verify is run on the file instead on the live + # cluster CIB + verify_runner = utils.cmd_runner(cib_file_override=filename) + # Request verbose output, otherwise we may only get an unhelpful + # message: + # Configuration invalid (with errors) (-V may provide more detail) + # verify_returncode is always expected to be non-zero to indicate + # invalid CIB - ve run the verify because the CIB is invalid + ( + verify_stdout, + verify_stderr, + verify_returncode, + verify_can_be_more_verbose, + ) = lib_pacemaker.verify(verify_runner, verbose=True) + return join_multilines([verify_stdout, verify_stderr]) + del lib modifiers.ensure_only_supported("--wait", "--config", "-f") if len(argv) > 2: @@ -846,8 +836,10 @@ def cluster_push(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: except (EnvironmentError, xml.parsers.expat.ExpatError) as e: utils.err("unable to parse new cib: %s" % e) + EXITCODE_INVALID_CIB = 78 + runner = utils.cmd_runner() + if diff_against: - runner = utils.cmd_runner() command = [ settings.crm_diff_exec, "--original", @@ -876,7 +868,18 @@ def cluster_push(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: ] output, stderr, retval = runner.run(command, patch) if retval != 0: - utils.err("unable to push cib\n" + stderr + output) + push_output = stderr + output + verify_output = ( + get_details_from_crm_verify() + if retval == EXITCODE_INVALID_CIB + else "" + ) + error_text = ( + f"{push_output}\n\n{verify_output}" + if verify_output.strip() + else push_output + ) + utils.err("unable to push cib\n" + error_text) else: command = ["cibadmin", "--replace", "--xml-file", filename] @@ -894,9 +897,28 @@ def cluster_push(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: " that." ) elif retval != 0: - utils.err("unable to push cib\n" + output) + verify_output = ( + get_details_from_crm_verify() + if retval == EXITCODE_INVALID_CIB + else "" + ) + error_text = ( + f"{output}\n\n{verify_output}" + if verify_output.strip() + else output + ) + utils.err("unable to push cib\n" + error_text) print_to_stderr("CIB updated") + try: + cib_errors = lib_pacemaker.get_cib_verification_errors(runner) + if cib_errors: + print_to_stderr("\n".join(cib_errors)) + except lib_pacemaker.BadApiResultFormat as e: + print_to_stderr( + f"Unable to verify CIB: {e.original_exception}\n" + f"crm_verify output:\n{e.pacemaker_response}" + ) if not modifiers.is_specified("--wait"): return @@ -913,7 +935,7 @@ def cluster_push(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: utils.err("\n".join(msg).strip()) -def cluster_edit(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: +def cluster_edit(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # noqa: PLR0912 """ Options: * --config - edit configuration section of CIB @@ -971,7 +993,7 @@ def cluster_edit(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: utils.err("$EDITOR environment variable is not set") -def get_cib(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: +def get_cib(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # noqa: PLR0912 """ Options: * --config show configuration section of CIB @@ -1165,7 +1187,7 @@ def node_remove(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: lib.cluster.remove_nodes(argv, force_flags=force_flags) -def cluster_uidgid( +def cluster_uidgid( # noqa: PLR0912 lib: Any, argv: Argv, modifiers: InputModifiers, silent_list: bool = False ) -> None: """ @@ -1282,7 +1304,7 @@ def cluster_reload(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # Completely tear down the cluster & remove config files # Code taken from cluster-clean script in pacemaker -def cluster_destroy(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: +def cluster_destroy(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # noqa: PLR0912 """ Options: * --all - destroy cluster on all cluster nodes => destroy whole cluster @@ -1351,30 +1373,23 @@ def cluster_destroy(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: else: print_to_stderr("Shutting down pacemaker/corosync services...") for service in ["pacemaker", "corosync-qdevice", "corosync"]: - try: + # It is safe to ignore error since we want it not to be running + # anyways. + with contextlib.suppress(LibraryError): utils.stop_service(service) - except LibraryError: - # It is safe to ignore error since we want it not to be running - # anyways. - pass print_to_stderr("Killing any remaining services...") kill_local_cluster_services() - try: + # previously errors were suppressed in here, let's keep it that way + # for now + with contextlib.suppress(Exception): utils.disableServices() - # pylint: disable=bare-except - except: - # previously errors were suppressed in here, let's keep it that way - # for now - pass - try: + + # it's not a big deal if sbd disable fails + with contextlib.suppress(Exception): service_manager = utils.get_service_manager() service_manager.disable( lib_sbd.get_sbd_service_name(service_manager) ) - # pylint: disable=bare-except - except: - # it's not a big deal if sbd disable fails - pass print_to_stderr("Removing all cluster configuration files...") dummy_output, dummy_retval = utils.run( @@ -1410,13 +1425,10 @@ def cluster_destroy(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: ";", ] ) - try: + # errors from deleting other files are suppressed as well we do not + # want to fail if qdevice was not set up + with contextlib.suppress(Exception): qdevice_net.client_destroy() - # pylint: disable=bare-except - except: - # errors from deleting other files are suppressed as well - # we do not want to fail if qdevice was not set up - pass def cluster_verify(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: @@ -1432,7 +1444,7 @@ def cluster_verify(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: lib.cluster.verify(verbose=modifiers.get("--full")) -def cluster_report(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: +def cluster_report(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # noqa: PLR0912 """ Options: * --force - overwrite existing file @@ -1457,7 +1469,7 @@ def cluster_report(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: os.remove(dest_outfile) except OSError as e: utils.err( - "Unable to remove " + dest_outfile + ": " + e.strerror + f"Unable to remove {dest_outfile}: {format_os_error(e)}" ) crm_report_opts = [] @@ -1480,11 +1492,7 @@ def cluster_report(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: utils.err("cluster is not configured on this node") newoutput = "" for line in output.split("\n"): - if ( - line.startswith("cat:") - or line.startswith("grep") - or line.startswith("tail") - ): + if line.startswith(("cat:", "grep", "tail")): continue if "We will attempt to remove" in line: continue @@ -1494,9 +1502,10 @@ def cluster_report(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: continue if "to diagnose" in line: continue + new_line = line if "--dest" in line: - line = line.replace("--dest", "") - newoutput = newoutput + line + "\n" + new_line = line.replace("--dest", "") + newoutput = newoutput + new_line + "\n" if retval != 0: utils.err(newoutput) print_to_stderr(newoutput) @@ -1534,14 +1543,14 @@ def send_local_configs( "Unable to set pcsd configs on {0}".format(node_name) ) # pylint: disable=bare-except - except: + except: # noqa: E722 err_msgs.append("Unable to communicate with pcsd") else: err_msgs.append("Unable to set pcsd configs") return err_msgs -def cluster_auth_cmd(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: +def cluster_auth_cmd(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: # noqa: PLR0912 """ Options: * --corosync_conf - corosync.conf file diff --git a/pcs/common/fencing_topology.py b/pcs/common/fencing_topology.py index 8999c2b2a..4e128ac47 100644 --- a/pcs/common/fencing_topology.py +++ b/pcs/common/fencing_topology.py @@ -1,3 +1,12 @@ -TARGET_TYPE_NODE = "node" -TARGET_TYPE_REGEXP = "regexp" -TARGET_TYPE_ATTRIBUTE = "attribute" +from typing import ( + Final, + NewType, + Union, +) + +FencingTargetType = NewType("FencingTargetType", str) +FencingTargetValue = Union[str, tuple[str, str]] + +TARGET_TYPE_NODE: Final = FencingTargetType("node") +TARGET_TYPE_REGEXP: Final = FencingTargetType("regexp") +TARGET_TYPE_ATTRIBUTE: Final = FencingTargetType("attribute") diff --git a/pcs/common/interface/dto.py b/pcs/common/interface/dto.py index 108f00a3d..c504bb5c7 100644 --- a/pcs/common/interface/dto.py +++ b/pcs/common/interface/dto.py @@ -10,7 +10,6 @@ Dict, Iterable, NewType, - Type, TypeVar, Union, ) @@ -58,7 +57,10 @@ def meta(name: str) -> Dict[str, str]: return metadata -def _is_compatible_type(_type: Type, arg_index: int) -> bool: +# _type is Any, since based on static code analysis it can be either of +# type[Any], str, None - depending on the step in dataclass instance +# initialization +def _is_compatible_type(_type: Any, arg_index: int) -> bool: return ( hasattr(_type, "__args__") and len(_type.__args__) >= arg_index @@ -67,7 +69,7 @@ def _is_compatible_type(_type: Type, arg_index: int) -> bool: def _convert_dict( - klass: Type[DataTransferObject], obj_dict: DtoPayload + klass: type[DataTransferObject], obj_dict: DtoPayload ) -> DtoPayload: new_dict = {} for _field in fields(klass): @@ -76,13 +78,14 @@ def _convert_dict( value = _convert_dict(_field.type, value) # type: ignore elif isinstance(value, list) and _is_compatible_type(_field.type, 0): value = [ - _convert_dict(_field.type.__args__[0], item) for item in value + # ignore _field.type may not have __args__ + # this is prevented by _is_compatible_type + _convert_dict(_field.type.__args__[0], item) # type: ignore[union-attr] + for item in value ] elif isinstance(value, dict) and _is_compatible_type(_field.type, 1): value = { - item_key: _convert_dict( - _field.type.__args__[1], item_val # type: ignore - ) + item_key: _convert_dict(_field.type.__args__[1], item_val) # type: ignore[union-attr,arg-type] for item_key, item_val in value.items() } elif isinstance(value, Enum): @@ -98,7 +101,7 @@ def to_dict(obj: DataTransferObject) -> DtoPayload: DTOTYPE = TypeVar("DTOTYPE", bound=DataTransferObject) -def _convert_payload(klass: Type[DTOTYPE], data: DtoPayload) -> DtoPayload: +def _convert_payload(klass: type[DTOTYPE], data: DtoPayload) -> DtoPayload: try: new_dict = dict(data) except ValueError as e: @@ -112,14 +115,14 @@ def _convert_payload(klass: Type[DTOTYPE], data: DtoPayload) -> DtoPayload: value = _convert_payload(_field.type, value) # type: ignore elif isinstance(value, list) and _is_compatible_type(_field.type, 0): value = [ - _convert_payload(_field.type.__args__[0], item) + # ignore _field.type may not have __args__ + # this is prevented by _is_compatible_type + _convert_payload(_field.type.__args__[0], item) # type: ignore for item in value ] elif isinstance(value, dict) and _is_compatible_type(_field.type, 1): value = { - item_key: _convert_payload( - _field.type.__args__[1], item_val # type: ignore - ) + item_key: _convert_payload(_field.type.__args__[1], item_val) # type: ignore[union-attr,arg-type] for item_key, item_val in value.items() } del new_dict[new_name] @@ -128,7 +131,7 @@ def _convert_payload(klass: Type[DTOTYPE], data: DtoPayload) -> DtoPayload: def from_dict( - cls: Type[DTOTYPE], data: DtoPayload, strict: bool = False + cls: type[DTOTYPE], data: DtoPayload, strict: bool = False ) -> DTOTYPE: return dacite.from_dict( data_class=cls, @@ -149,6 +152,20 @@ def from_dict( permissions_types.PermissionAccessType, permissions_types.PermissionTargetType, ], + type_hooks={ + # JSON does not support tuples, only lists. However, tuples are + # used e.g. to express fixed-length structures. If a tuple is + # expected and a list is provided, we convert it to a tuple. + # Unfortunately, we cannot apply this rule generically to all + # tuples, so we must handle specific cases manually. + # + # Covered cases: + # * acl.create_role: + # permission_info_list: list[tuple[str, str, str]] + tuple[str, str, str]: ( + lambda v: tuple(v) if isinstance(v, list) else v + ), + }, strict=strict, ), ) @@ -161,5 +178,5 @@ def to_dto(self) -> Any: class ImplementsFromDto: @classmethod - def from_dto(cls: Type[T], dto_obj: Any) -> T: + def from_dto(cls: type[T], dto_obj: Any) -> T: raise NotImplementedError() diff --git a/pcs/common/node_communicator.py b/pcs/common/node_communicator.py index 15dcbf006..5c3c62fbb 100644 --- a/pcs/common/node_communicator.py +++ b/pcs/common/node_communicator.py @@ -416,7 +416,7 @@ def __wait_for_multi_handle(self) -> None: while need_to_wait: timeout = self._multi_handle.timeout() if timeout == 0: - # if timeout == 0 then there is something to precess already + # if timeout == 0 then there is something to process already return select_timeout = ( timeout / 1000.0 diff --git a/pcs/common/pacemaker/alert.py b/pcs/common/pacemaker/alert.py new file mode 100644 index 000000000..7ae9929be --- /dev/null +++ b/pcs/common/pacemaker/alert.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import Optional, Sequence + +from pcs.common.interface.dto import DataTransferObject +from pcs.common.pacemaker.nvset import CibNvsetDto + + +@dataclass(frozen=True) +class CibAlertRecipientDto(DataTransferObject): + id: str + value: str + description: Optional[str] + meta_attributes: Sequence[CibNvsetDto] + instance_attributes: Sequence[CibNvsetDto] + + +@dataclass(frozen=True) +class CibAlertSelectAttributeDto(DataTransferObject): + id: str + name: str + + +@dataclass(frozen=True) +class CibAlertSelectDto(DataTransferObject): + nodes: bool + fencing: bool + resources: bool + attributes: bool + attributes_select: Sequence[CibAlertSelectAttributeDto] + + +@dataclass(frozen=True) +class CibAlertDto(DataTransferObject): + id: str + path: str + description: Optional[str] + recipients: Sequence[CibAlertRecipientDto] + select: Optional[CibAlertSelectDto] + meta_attributes: Sequence[CibNvsetDto] + instance_attributes: Sequence[CibNvsetDto] + + +@dataclass(frozen=True) +class CibAlertListDto(DataTransferObject): + alerts: Sequence[CibAlertDto] diff --git a/pcs/common/pacemaker/constraint/all.py b/pcs/common/pacemaker/constraint/all.py index 769f904c8..f8225eeb5 100644 --- a/pcs/common/pacemaker/constraint/all.py +++ b/pcs/common/pacemaker/constraint/all.py @@ -50,7 +50,7 @@ def _get_constraint_ids( CibConstraintTicketDto, CibConstraintTicketSetDto, ] - ] + ], ) -> list[str]: return [ constraint_dto.attributes.constraint_id @@ -64,7 +64,7 @@ def _get_location_rule_ids( CibConstraintLocationDto, CibConstraintLocationSetDto, ] - ] + ], ) -> list[str]: return [ rule_dto.id diff --git a/pcs/common/pacemaker/fencing_topology.py b/pcs/common/pacemaker/fencing_topology.py new file mode 100644 index 000000000..603563cae --- /dev/null +++ b/pcs/common/pacemaker/fencing_topology.py @@ -0,0 +1,42 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Union + +from pcs.common.interface.dto import DataTransferObject + + +@dataclass(frozen=True) +class CibFencingLevelNodeDto(DataTransferObject): + id: str + target: str + index: int + devices: list[str] + + +@dataclass(frozen=True) +class CibFencingLevelRegexDto(DataTransferObject): + id: str + target_pattern: str + index: int + devices: list[str] + + +@dataclass(frozen=True) +class CibFencingLevelAttributeDto(DataTransferObject): + id: str + target_attribute: str + target_value: str + index: int + devices: list[str] + + +CibFencingLevel = Union[ + CibFencingLevelNodeDto, CibFencingLevelRegexDto, CibFencingLevelAttributeDto +] + + +@dataclass(frozen=True) +class CibFencingTopologyDto(DataTransferObject): + target_node: Sequence[CibFencingLevelNodeDto] + target_regex: Sequence[CibFencingLevelRegexDto] + target_attribute: Sequence[CibFencingLevelAttributeDto] diff --git a/pcs/common/pacemaker/node.py b/pcs/common/pacemaker/node.py new file mode 100644 index 000000000..494e0d135 --- /dev/null +++ b/pcs/common/pacemaker/node.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Optional, Sequence + +from pcs.common.interface.dto import DataTransferObject +from pcs.common.pacemaker.nvset import CibNvsetDto + + +@dataclass(frozen=True) +class CibNodeDto(DataTransferObject): + id: str + uname: str + description: Optional[str] + score: Optional[str] + type: Optional[str] + instance_attributes: Sequence[CibNvsetDto] + utilization: Sequence[CibNvsetDto] + + +@dataclass(frozen=True) +class CibNodeListDto(DataTransferObject): + nodes: Sequence[CibNodeDto] diff --git a/pcs/common/pacemaker/resource/list.py b/pcs/common/pacemaker/resource/list.py index 5b45bddb3..124adad24 100644 --- a/pcs/common/pacemaker/resource/list.py +++ b/pcs/common/pacemaker/resource/list.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from itertools import chain from typing import Sequence from pcs.common.interface.dto import DataTransferObject @@ -15,3 +16,22 @@ class CibResourcesDto(DataTransferObject): clones: Sequence[CibResourceCloneDto] groups: Sequence[CibResourceGroupDto] bundles: Sequence[CibResourceBundleDto] + + +def get_all_resources_ids(resources_dto: CibResourcesDto) -> set[str]: + return set( + chain( + (primitive.id for primitive in resources_dto.primitives), + (group.id for group in resources_dto.groups), + (clone.id for clone in resources_dto.clones), + (bundle.id for bundle in resources_dto.bundles), + ) + ) + + +def get_stonith_resources_ids(resources_dto: CibResourcesDto) -> set[str]: + return { + primitive.id + for primitive in resources_dto.primitives + if primitive.agent_name.standard == "stonith" + } diff --git a/pcs/common/pacemaker/tag.py b/pcs/common/pacemaker/tag.py new file mode 100644 index 000000000..63c55464f --- /dev/null +++ b/pcs/common/pacemaker/tag.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Sequence + +from pcs.common.interface.dto import DataTransferObject +from pcs.common.types import StringSequence + + +@dataclass(frozen=True) +class CibTagDto(DataTransferObject): + id: str + idref_list: StringSequence + + +@dataclass(frozen=True) +class CibTagListDto(DataTransferObject): + tags: Sequence[CibTagDto] diff --git a/pcs/common/pacemaker/types.py b/pcs/common/pacemaker/types.py index 4d0076f4d..8a58afd80 100644 --- a/pcs/common/pacemaker/types.py +++ b/pcs/common/pacemaker/types.py @@ -2,23 +2,27 @@ class CibResourceDiscovery(str): + __slots__ = () ALWAYS = cast("CibResourceDiscovery", "always") NEVER = cast("CibResourceDiscovery", "never") EXCLUSIVE = cast("CibResourceDiscovery", "exclusive") class CibResourceSetOrdering(str): + __slots__ = () GROUP = cast("CibResourceSetOrdering", "group") LISTED = cast("CibResourceSetOrdering", "listed") class CibResourceSetOrderType(str): + __slots__ = () OPTIONAL = cast("CibResourceSetOrderType", "Optional") MANDATORY = cast("CibResourceSetOrderType", "Mandatory") SERIALIZE = cast("CibResourceSetOrderType", "Serialize") class CibTicketLossPolicy(str): + __slots__ = () STOP = cast("CibTicketLossPolicy", "stop") DEMOTE = cast("CibTicketLossPolicy", "demote") FENCE = cast("CibTicketLossPolicy", "fence") diff --git a/pcs/common/pcs_pycurl.py b/pcs/common/pcs_pycurl.py index 01de83120..196cc6a82 100644 --- a/pcs/common/pcs_pycurl.py +++ b/pcs/common/pcs_pycurl.py @@ -2,7 +2,7 @@ # pylint: disable=unused-wildcard-import # pylint: disable=wildcard-import -from pycurl import * +from pycurl import * # noqa: F403 # This package defines constants which are not present in some older versions # of pycurl but pcs needs to use them diff --git a/pcs/common/reports/__init__.py b/pcs/common/reports/__init__.py index 1b16ad1b6..0d2a38586 100644 --- a/pcs/common/reports/__init__.py +++ b/pcs/common/reports/__init__.py @@ -14,6 +14,7 @@ ReportItemMessage, ReportItemSeverity, get_severity, + get_severity_from_flags, ) from .processor import ( ReportProcessor, diff --git a/pcs/common/reports/codes.py b/pcs/common/reports/codes.py index b28198e96..855fdd7a0 100644 --- a/pcs/common/reports/codes.py +++ b/pcs/common/reports/codes.py @@ -2,7 +2,7 @@ # pylint: disable=wildcard-import # Wildcard import of deprecated report codes will prevent creation of a new # report with the code of deprecated report -from .deprecated_codes import * +from .deprecated_codes import * # noqa: F403 from .types import ForceCode as F from .types import MessageCode as M @@ -51,6 +51,7 @@ AGENT_SELF_VALIDATION_RESULT = M("AGENT_SELF_VALIDATION_RESULT") BAD_CLUSTER_STATE_FORMAT = M("BAD_CLUSTER_STATE_FORMAT") BAD_CLUSTER_STATE_DATA = M("BAD_CLUSTER_STATE_DATA") +BAD_PCMK_API_RESPONSE_FORMAT = M("BAD_PCMK_API_RESPONSE_FORMAT") BOOTH_ADDRESS_DUPLICATION = M("BOOTH_ADDRESS_DUPLICATION") BOOTH_ALREADY_IN_CIB = M("BOOTH_ALREADY_IN_CIB") BOOTH_AUTHFILE_NOT_USED = M("BOOTH_AUTHFILE_NOT_USED") @@ -72,9 +73,12 @@ BOOTH_NOT_EXISTS_IN_CIB = M("BOOTH_NOT_EXISTS_IN_CIB") BOOTH_PATH_NOT_EXISTS = M("BOOTH_PATH_NOT_EXISTS") BOOTH_PEERS_STATUS_ERROR = M("BOOTH_PEERS_STATUS_ERROR") +BOOTH_TICKET_CHANGING_STATE = M("BOOTH_TICKET_CHANGING_STATE") +BOOTH_TICKET_CLEANUP = M("BOOTH_TICKET_CLEANUP") BOOTH_TICKET_DOES_NOT_EXIST = M("BOOTH_TICKET_DOES_NOT_EXIST") BOOTH_TICKET_DUPLICATE = M("BOOTH_TICKET_DUPLICATE") BOOTH_TICKET_NAME_INVALID = M("BOOTH_TICKET_NAME_INVALID") +BOOTH_TICKET_NOT_IN_CIB = M("BOOTH_TICKET_NOT_IN_CIB") BOOTH_TICKET_OPERATION_FAILED = M("BOOTH_TICKET_OPERATION_FAILED") BOOTH_TICKET_STATUS_ERROR = M("BOOTH_TICKET_STATUS_ERROR") BOOTH_UNSUPPORTED_FILE_LOCATION = M("BOOTH_UNSUPPORTED_FILE_LOCATION") @@ -88,6 +92,13 @@ CANNOT_BAN_RESOURCE_STOPPED_NO_NODE_SPECIFIED = M( "CANNOT_BAN_RESOURCE_STOPPED_NO_NODE_SPECIFIED" ) +STOPPING_RESOURCES_BEFORE_DELETING = M("STOPPING_RESOURCES_BEFORE_DELETING") +STOPPING_RESOURCES_BEFORE_DELETING_SKIPPED = M( + "STOPPING_RESOURCES_BEFORE_DELETING_SKIPPED" +) +CANNOT_STOP_RESOURCES_BEFORE_DELETING = M( + "CANNOT_STOP_RESOURCES_BEFORE_DELETING" +) CANNOT_CREATE_DEFAULT_CLUSTER_PROPERTY_SET = M( "CANNOT_CREATE_DEFAULT_CLUSTER_PROPERTY_SET" ) @@ -128,6 +139,9 @@ CIB_ALERT_RECIPIENT_ALREADY_EXISTS = M("CIB_ALERT_RECIPIENT_ALREADY_EXISTS") CIB_ALERT_RECIPIENT_VALUE_INVALID = M("CIB_ALERT_RECIPIENT_VALUE_INVALID") CIB_CANNOT_FIND_MANDATORY_SECTION = M("CIB_CANNOT_FIND_MANDATORY_SECTION") +CIB_CLUSTER_NAME_REMOVAL_FAILED = M("CIB_CLUSTER_NAME_REMOVAL_FAILED") +CIB_CLUSTER_NAME_REMOVAL_STARTED = M("CIB_CLUSTER_NAME_REMOVAL_STARTED") +CIB_CLUSTER_NAME_REMOVED = M("CIB_CLUSTER_NAME_REMOVED") CIB_DIFF_ERROR = M("CIB_DIFF_ERROR") CIB_FENCING_LEVEL_ALREADY_EXISTS = M("CIB_FENCING_LEVEL_ALREADY_EXISTS") CIB_FENCING_LEVEL_DOES_NOT_EXIST = M("CIB_FENCING_LEVEL_DOES_NOT_EXIST") @@ -136,9 +150,21 @@ CIB_LOAD_ERROR_GET_NODES_FOR_VALIDATION = M( "CIB_LOAD_ERROR_GET_NODES_FOR_VALIDATION" ) +CIB_NODE_RENAME_ACLS_EXIST = M("CIB_NODE_RENAME_ACLS_EXIST") +CIB_NODE_RENAME_ELEMENT_UPDATED = M("CIB_NODE_RENAME_ELEMENT_UPDATED") +CIB_NODE_RENAME_FENCING_LEVEL_PATTERN_EXISTS = M( + "CIB_NODE_RENAME_FENCING_LEVEL_PATTERN_EXISTS" +) +CIB_NODE_RENAME_NEW_NODE_NOT_IN_COROSYNC = M( + "CIB_NODE_RENAME_NEW_NODE_NOT_IN_COROSYNC" +) +CIB_NODE_RENAME_NO_CHANGE = M("CIB_NODE_RENAME_NO_CHANGE") +CIB_NODE_RENAME_OLD_NODE_IN_COROSYNC = M("CIB_NODE_RENAME_OLD_NODE_IN_COROSYNC") CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID = M("CIB_NVSET_AMBIGUOUS_PROVIDE_NVSET_ID") CIB_LOAD_ERROR_SCOPE_MISSING = M("CIB_LOAD_ERROR_SCOPE_MISSING") CIB_PUSH_ERROR = M("CIB_PUSH_ERROR") +CIB_REMOVE_REFERENCES = M("CIB_REMOVE_REFERENCES") +CIB_REMOVE_RESOURCES = M("CIB_REMOVE_RESOURCES") CIB_REMOVE_DEPENDANT_ELEMENTS = M("CIB_REMOVE_DEPENDANT_ELEMENTS") CIB_SAVE_TMP_ERROR = M("CIB_SAVE_TMP_ERROR") CIB_SIMULATE_ERROR = M("CIB_SIMULATE_ERROR") @@ -147,6 +173,7 @@ "CIB_UPGRADE_FAILED_TO_MINIMAL_REQUIRED_VERSION" ) CIB_UPGRADE_SUCCESSFUL = M("CIB_UPGRADE_SUCCESSFUL") +CIB_XML_MISSING = M("CIB_XML_MISSING") CLUSTER_DESTROY_STARTED = M("CLUSTER_DESTROY_STARTED") CLUSTER_DESTROY_SUCCESS = M("CLUSTER_DESTROY_SUCCESS") CLUSTER_ENABLE_STARTED = M("CLUSTER_ENABLE_STARTED") @@ -164,6 +191,9 @@ CLUSTER_WILL_BE_DESTROYED = M("CLUSTER_WILL_BE_DESTROYED") COMMAND_INVALID_PAYLOAD = M("COMMAND_INVALID_PAYLOAD") COMMAND_UNKNOWN = M("COMMAND_UNKNOWN") +CONFIGURED_RESOURCE_MISSING_IN_STATUS = M( + "CONFIGURED_RESOURCE_MISSING_IN_STATUS" +) LIVE_ENVIRONMENT_NOT_CONSISTENT = M("LIVE_ENVIRONMENT_NOT_CONSISTENT") LIVE_ENVIRONMENT_REQUIRED = M("LIVE_ENVIRONMENT_REQUIRED") LIVE_ENVIRONMENT_REQUIRED_FOR_LOCAL_NODE = M( @@ -221,6 +251,15 @@ COROSYNC_LINK_NUMBER_DUPLICATION = M("COROSYNC_LINK_NUMBER_DUPLICATION") COROSYNC_NODE_ADDRESS_COUNT_MISMATCH = M("COROSYNC_NODE_ADDRESS_COUNT_MISMATCH") COROSYNC_NODE_CONFLICT_CHECK_SKIPPED = M("COROSYNC_NODE_CONFLICT_CHECK_SKIPPED") +COROSYNC_NODE_RENAME_ADDRS_MATCH_OLD_NAME = M( + "COROSYNC_NODE_RENAME_ADDRS_MATCH_OLD_NAME" +) +COROSYNC_NODE_RENAME_NEW_NODE_ALREADY_EXISTS = M( + "COROSYNC_NODE_RENAME_NEW_NODE_ALREADY_EXISTS" +) +COROSYNC_NODE_RENAME_OLD_NODE_NOT_FOUND = M( + "COROSYNC_NODE_RENAME_OLD_NODE_NOT_FOUND" +) COROSYNC_NODES_MISSING = M("COROSYNC_NODES_MISSING") COROSYNC_NOT_RUNNING_CHECK_FINISHED_RUNNING = M( "COROSYNC_NOT_RUNNING_CHECK_FINISHED_RUNNING" @@ -264,6 +303,7 @@ DEFAULTS_CAN_BE_OVERRIDDEN = M("DEFAULTS_CAN_BE_OVERRIDDEN") DEPRECATED_OPTION = M("DEPRECATED_OPTION") DEPRECATED_OPTION_VALUE = M("DEPRECATED_OPTION_VALUE") +DLM_CLUSTER_RENAME_NEEDED = M("DLM_CLUSTER_RENAME_NEEDED") DR_CONFIG_ALREADY_EXIST = M("DR_CONFIG_ALREADY_EXIST") DR_CONFIG_DOES_NOT_EXIST = M("DR_CONFIG_DOES_NOT_EXIST") DUPLICATE_CONSTRAINTS_EXIST = M("DUPLICATE_CONSTRAINTS_EXIST") @@ -280,6 +320,8 @@ FILE_IO_ERROR = M("FILE_IO_ERROR") FILE_REMOVE_FROM_NODE_ERROR = M("FILE_REMOVE_FROM_NODE_ERROR") FILE_REMOVE_FROM_NODE_SUCCESS = M("FILE_REMOVE_FROM_NODE_SUCCESS") +GFS2_LOCK_TABLE_RENAME_NEEDED = M("GFS2_LOCK_TABLE_RENAME_NEEDED") +GUEST_NODE_NAME_ALREADY_EXISTS = M("GUEST_NODE_NAME_ALREADY_EXISTS") HOST_NOT_FOUND = M("HOST_NOT_FOUND") HOST_ALREADY_AUTHORIZED = M("HOST_ALREADY_AUTHORIZED") HOST_ALREADY_IN_CLUSTER_CONFIG = M("HOST_ALREADY_IN_CLUSTER_CONFIG") @@ -302,6 +344,7 @@ MULTIPLE_RESULTS_FOUND = M("MULTIPLE_RESULTS_FOUND") MUTUALLY_EXCLUSIVE_OPTIONS = M("MUTUALLY_EXCLUSIVE_OPTIONS") NO_ACTION_NECESSARY = M("NO_ACTION_NECESSARY") +NO_STONITH_MEANS_WOULD_BE_LEFT = M("NO_STONITH_MEANS_WOULD_BE_LEFT") NODE_ADDRESSES_ALREADY_EXIST = M("NODE_ADDRESSES_ALREADY_EXIST") NODE_ADDRESSES_CANNOT_BE_EMPTY = M("NODE_ADDRESSES_CANNOT_BE_EMPTY") NODE_ADDRESSES_DUPLICATION = M("NODE_ADDRESSES_DUPLICATION") @@ -333,6 +376,7 @@ NODE_NAMES_ALREADY_EXIST = M("NODE_NAMES_ALREADY_EXIST") NODE_NAMES_DUPLICATION = M("NODE_NAMES_DUPLICATION") NODE_NOT_FOUND = M("NODE_NOT_FOUND") +NODE_RENAME_NAMES_EQUAL = M("NODE_RENAME_NAMES_EQUAL") NODE_REMOVE_IN_PACEMAKER_FAILED = M("NODE_REMOVE_IN_PACEMAKER_FAILED") NONE_HOST_FOUND = M("NONE_HOST_FOUND") NODE_USED_AS_TIE_BREAKER = M("NODE_USED_AS_TIE_BREAKER") @@ -342,8 +386,9 @@ NOT_AUTHORIZED = M("NOT_AUTHORIZED") OBJECT_WITH_ID_IN_UNEXPECTED_CONTEXT = M("OBJECT_WITH_ID_IN_UNEXPECTED_CONTEXT") OMITTING_NODE = M("OMITTING_NODE") -PACEMAKER_SIMULATION_RESULT = M("PACEMAKER_SIMULATION_RESULT") PACEMAKER_LOCAL_NODE_NAME_NOT_FOUND = M("PACEMAKER_LOCAL_NODE_NAME_NOT_FOUND") +PACEMAKER_RUNNING = M("PACEMAKER_RUNNING") +PACEMAKER_SIMULATION_RESULT = M("PACEMAKER_SIMULATION_RESULT") PARSE_ERROR_COROSYNC_CONF = M("PARSE_ERROR_COROSYNC_CONF") PARSE_ERROR_COROSYNC_CONF_EXTRA_CHARACTERS_AFTER_OPENING_BRACE = M( "PARSE_ERROR_COROSYNC_CONF_EXTRA_CHARACTERS_AFTER_OPENING_BRACE" @@ -446,6 +491,13 @@ ) RESOURCE_REFRESH_ERROR = M("RESOURCE_REFRESH_ERROR") RESOURCE_REFRESH_TOO_TIME_CONSUMING = M("RESOURCE_REFRESH_TOO_TIME_CONSUMING") +RESOURCE_RESTART_ERROR = M("RESOURCE_RESTART_ERROR") +RESOURCE_RESTART_NODE_IS_FOR_MULTIINSTANCE_ONLY = M( + "RESOURCE_RESTART_NODE_IS_FOR_MULTIINSTANCE_ONLY" +) +RESOURCE_RESTART_USING_PARENT_RESOURCE = M( + "RESOURCE_RESTART_USING_PARENT_RESOURCE" +) RESOURCE_RUNNING_ON_NODES = M("RESOURCE_RUNNING_ON_NODES") RESOURCE_UNMOVE_UNBAN_PCMK_ERROR = M("RESOURCE_UNMOVE_UNBAN_PCMK_ERROR") RESOURCE_UNMOVE_UNBAN_PCMK_SUCCESS = M("RESOURCE_UNMOVE_UNBAN_PCMK_SUCCESS") @@ -602,7 +654,13 @@ ) USE_COMMAND_NODE_ADD_REMOTE = M("USE_COMMAND_NODE_ADD_REMOTE") USE_COMMAND_NODE_ADD_GUEST = M("USE_COMMAND_NODE_ADD_GUEST") +USE_COMMAND_NODE_REMOVE_REMOTE = M("USE_COMMAND_NODE_REMOVE_REMOTE") USE_COMMAND_NODE_REMOVE_GUEST = M("USE_COMMAND_NODE_REMOVE_GUEST") +USE_COMMAND_REMOVE_AND_ADD_GUEST_NODE = M( + "USE_COMMAND_REMOVE_AND_ADD_GUEST_NODE" +) +REMOTE_NODE_REMOVAL_INCOMPLETE = M("REMOTE_NODE_REMOVAL_INCOMPLETE") +GUEST_NODE_REMOVAL_INCOMPLETE = M("GUEST_NODE_REMOVAL_INCOMPLETE") USING_DEFAULT_ADDRESS_FOR_HOST = M("USING_DEFAULT_ADDRESS_FOR_HOST") USING_DEFAULT_WATCHDOG = M("USING_DEFAULT_WATCHDOG") WAIT_FOR_IDLE_STARTED = M("WAIT_FOR_IDLE_STARTED") diff --git a/pcs/common/reports/conversions.py b/pcs/common/reports/conversions.py index 4c3f36f3b..206971c66 100644 --- a/pcs/common/reports/conversions.py +++ b/pcs/common/reports/conversions.py @@ -39,7 +39,7 @@ def report_dto_to_item( def _create_report_msg_map() -> Dict[str, type]: result: Dict[str, type] = {} for report_msg_cls in get_all_subclasses(messages.ReportItemMessage): - code = report_msg_cls._code # pylint: disable=protected-access + code = report_msg_cls._code # pylint: disable=protected-access # noqa: SLF001 if code: if code in result: raise AssertionError() diff --git a/pcs/common/reports/item.py b/pcs/common/reports/item.py index 262845b21..b3d946958 100644 --- a/pcs/common/reports/item.py +++ b/pcs/common/reports/item.py @@ -19,6 +19,7 @@ ) from .types import ( ForceCode, + ForceFlags, MessageCode, SeverityLevel, ) @@ -79,6 +80,27 @@ def get_severity( return ReportItemSeverity(ReportItemSeverity.ERROR, force_code) +def get_severity_from_flags( + force_code: Optional[ForceCode], force_flags: ForceFlags +) -> ReportItemSeverity: + """ + Returns warning/error severity for report creation depending on whether the + force_code is in force_flags. + + force_code -- the force code by which the report can be overridden + force_flags -- force flags specified to the command + + TODO: When pcs starts using other force codes than all-mighty force, this + function can be expanded to allow for checking the weaker force code with + automatic override by the all-mighty force. For example, if force_code is + weak_force, and force_flags contain force but not weak_force, the function + would return warning severity. + """ + if force_code in force_flags: + return ReportItemSeverity(ReportItemSeverity.WARNING) + return ReportItemSeverity(ReportItemSeverity.ERROR, force_code) + + @dataclass(frozen=True, init=False) class ReportItemMessage(ImplementsToDto): _code = MessageCode("") @@ -98,7 +120,7 @@ def to_dto(self) -> ReportItemMessageDto: annotations = self.__class__.__annotations__ except AttributeError as e: raise AssertionError() from e - for attr_name in annotations.keys(): + for attr_name in annotations: if attr_name.startswith("_") or attr_name in ("message",): continue attr_val = getattr(self, attr_name) diff --git a/pcs/common/reports/messages.py b/pcs/common/reports/messages.py index ccf32a032..783342fdb 100644 --- a/pcs/common/reports/messages.py +++ b/pcs/common/reports/messages.py @@ -9,6 +9,7 @@ Any, Dict, List, + Literal, Mapping, Optional, Tuple, @@ -17,7 +18,11 @@ ) from pcs.common import file_type_codes -from pcs.common.fencing_topology import TARGET_TYPE_ATTRIBUTE +from pcs.common.fencing_topology import ( + TARGET_TYPE_ATTRIBUTE, + FencingTargetType, + FencingTargetValue, +) from pcs.common.file import ( FileAction, RawFileError, @@ -26,6 +31,7 @@ ResourceAgentNameDto, get_resource_agent_full_name, ) +from pcs.common.resource_status import ResourceState from pcs.common.str_tools import ( format_list, format_list_custom_last_separator, @@ -55,9 +61,11 @@ def _stdout_stderr_to_string(stdout: str, stderr: str, prefix: str = "") -> str: new_lines = [prefix] if prefix else [] - for line in stdout.splitlines() + stderr.splitlines(): - if line.strip(): - new_lines.append(line) + new_lines.extend( + line + for line in stdout.splitlines() + stderr.splitlines() + if line.strip() + ) return "\n".join(new_lines) @@ -73,10 +81,10 @@ def _resource_move_ban_clear_master_resource_not_promotable( def _resource_move_ban_pcmk_success(stdout: str, stderr: str) -> str: new_lines = [] - for line in stdout.splitlines() + stderr.splitlines(): - if not line.strip(): + for output_line in stdout.splitlines() + stderr.splitlines(): + if not output_line.strip(): continue - line = line.replace( + line = output_line.replace( "WARNING: Creating rsc_location constraint", "Warning: Creating location constraint", ) @@ -90,11 +98,13 @@ def _resource_move_ban_pcmk_success(stdout: str, stderr: str) -> str: def _format_fencing_level_target( - target_type: Optional[str], target_value: Any + target_type: FencingTargetType, + target_value: FencingTargetValue, ) -> str: if target_type == TARGET_TYPE_ATTRIBUTE: return f"{target_value[0]}={target_value[1]}" - return target_value + # Other target types guarantee values to be only strings + return str(target_value) def _format_booth_default(value: Optional[str], template: str) -> str: @@ -102,7 +112,10 @@ def _format_booth_default(value: Optional[str], template: str) -> str: def _key_numeric(item: str) -> Tuple[int, str]: - return (int(item), item) if item.isdigit() else (-1, item) + try: + return int(item), item + except ValueError: + return -1, item _add_remove_container_translation = { @@ -137,12 +150,17 @@ def _key_numeric(item: str) -> Tuple[int, str]: "acl_permission": "ACL permission", "acl_role": "ACL role", "acl_target": "ACL user", + "fencing-level": "fencing level", # Pacemaker-2.0 deprecated masters. Masters are now called promotable # clones. We treat masters as clones. Do not report we were doing something # with a master, say we were doing it with a clone instead. "master": "clone", "primitive": "resource", + "resource_set": "resource set", + "rsc_colocation": "colocation constraint", "rsc_location": "location constraint", + "rsc_order": "order constraint", + "rsc_ticket": "ticket constraint", } _type_articles = { "ACL group": "an", @@ -260,7 +278,7 @@ def _stonith_watchdog_timeout_reason_to_str( }.get(reason, reason) -@dataclass(frozen=True, init=False) +@dataclass(frozen=True) class LegacyCommonMessage(ReportItemMessage): """ This class is used for legacy report transport protocol from @@ -268,22 +286,19 @@ class LegacyCommonMessage(ReportItemMessage): should be replaced with transporting DTOs of reports in the future. """ - def __init__( - self, code: types.MessageCode, info: Mapping[str, Any], message: str - ) -> None: - self.__code = code - self.info = info - self._message = message + legacy_code: types.MessageCode + legacy_info: Mapping[str, Any] + legacy_message: str @property def message(self) -> str: - return self._message + return self.legacy_message def to_dto(self) -> ReportItemMessageDto: return ReportItemMessageDto( - code=self.__code, + code=self.legacy_code, message=self.message, - payload=dict(self.info), + payload=dict(self.legacy_info), ) @@ -1602,9 +1617,7 @@ class ParseErrorCorosyncConfMissingSectionNameBeforeOpeningBrace( Corosync config cannot be parsed due to a section name missing before { """ - _code = ( - codes.PARSE_ERROR_COROSYNC_CONF_MISSING_SECTION_NAME_BEFORE_OPENING_BRACE - ) + _code = codes.PARSE_ERROR_COROSYNC_CONF_MISSING_SECTION_NAME_BEFORE_OPENING_BRACE @property def message(self) -> str: @@ -1634,9 +1647,7 @@ class ParseErrorCorosyncConfExtraCharactersBeforeOrAfterClosingBrace( Corosync config cannot be parsed due to extra characters before or after } """ - _code = ( - codes.PARSE_ERROR_COROSYNC_CONF_EXTRA_CHARACTERS_BEFORE_OR_AFTER_CLOSING_BRACE - ) + _code = codes.PARSE_ERROR_COROSYNC_CONF_EXTRA_CHARACTERS_BEFORE_OR_AFTER_CLOSING_BRACE @property def message(self) -> str: @@ -1929,7 +1940,9 @@ class CorosyncLinkNumberDuplication(ReportItemMessage): @property def message(self) -> str: - nums = format_list(sorted(self.link_number_list, key=_key_numeric)) + nums = format_list_dont_sort( + sorted(self.link_number_list, key=_key_numeric) + ) return f"Link numbers must be unique, duplicate link numbers: {nums}" @@ -2083,6 +2096,73 @@ def message(self) -> str: return "No nodes have been specified" +@dataclass(frozen=True) +class CorosyncNodeRenameOldNodeNotFound(ReportItemMessage): + """ + The old node name was not found in corosync.conf. + + old_name -- the node name that was not found + """ + + old_name: str + _code = codes.COROSYNC_NODE_RENAME_OLD_NODE_NOT_FOUND + + @property + def message(self) -> str: + return ( + f"Node '{self.old_name}' was not found in corosync.conf, " + "unable to rename" + ) + + +@dataclass(frozen=True) +class CorosyncNodeRenameNewNodeAlreadyExists(ReportItemMessage): + """ + The new node name already exists in corosync.conf. + + new_name -- the node name that already exists + """ + + new_name: str + _code = codes.COROSYNC_NODE_RENAME_NEW_NODE_ALREADY_EXISTS + + @property + def message(self) -> str: + return ( + f"Node '{self.new_name}' already exists in corosync.conf, " + "unable to rename" + ) + + +@dataclass(frozen=True) +class CorosyncNodeRenameAddrsMatchOldName(ReportItemMessage): + """ + Some ring addresses match the old node name after rename. + + old_name -- the old node name + new_name -- the new node name + node_addrs -- dict mapping node name (or nodeid) to list of address + attribute names that still reference old_name + """ + + old_name: str + new_name: str + node_addrs: dict[str, list[str]] + _code = codes.COROSYNC_NODE_RENAME_ADDRS_MATCH_OLD_NAME + + @property + def message(self) -> str: + addrs = "; ".join( + f"{node}: {', '.join(addr_list)}" + for node, addr_list in sorted(self.node_addrs.items()) + ) + return ( + f"Node '{self.old_name}' has been renamed to" + f" '{self.new_name}', but the following addresses still" + f" reference the old name: {addrs}" + ) + + @dataclass(frozen=True) class CorosyncTooManyLinksOptions(ReportItemMessage): """ @@ -2664,10 +2744,17 @@ class IdNotFound(ReportItemMessage): """ Specified id does not exist in CIB, user referenced a nonexisting id + Context provides info about what CIB subsection was searched - group for + example. + + Examples: + resource 'r1' does not exist + there is no resource 'r1' in the group 'g1' + id -- specified id expected_types -- list of id's roles - expected types with the id - context_type -- context_id's role / type - context_id -- specifies the search area + context_type -- subsection role / type + context_id -- id of the subsection """ id: str # pylint: disable=invalid-name @@ -3248,6 +3335,24 @@ def message(self) -> str: ) +@dataclass(frozen=True) +class BadPcmkApiResponseFormat(ReportItemMessage): + """ + Structured response from pacemaker doesn't match expected format / schema + """ + + _code = codes.BAD_PCMK_API_RESPONSE_FORMAT + reason: str + api_response: str + + @property + def message(self) -> str: + return ( + "Cannot process pacemaker response due to a parse error: " + f"{self.reason}\n{self.api_response}" + ) + + @dataclass(frozen=True) class BadClusterStateFormat(ReportItemMessage): """ @@ -3377,7 +3482,73 @@ class WaitForIdleNotLiveCluster(ReportItemMessage): @property def message(self) -> str: - return "Cannot use 'mocked CIB' together with 'wait'" + return "Cannot pass CIB together with 'wait'" + + +@dataclass(frozen=True) +class ResourceRestartError(ReportItemMessage): + """ + An error occurred when restarting a resource in pacemaker + + reason -- error description + resource -- resource which has been restarted + node -- node where the resource has been restarted + """ + + reason: str + resource: str + node: Optional[str] = None + _code = codes.RESOURCE_RESTART_ERROR + + @property + def message(self) -> str: + return f"Unable to restart resource '{self.resource}':\n{self.reason}" + + +@dataclass(frozen=True) +class ResourceRestartNodeIsForMultiinstanceOnly(ReportItemMessage): + """ + Restart can be limited to a specified node only for multiinstance resources + + resource -- resource to be restarted + resource_type -- actual type of the resource + node -- node where the resource was to be restarted + """ + + resource: str + resource_type: str + node: str + _code = codes.RESOURCE_RESTART_NODE_IS_FOR_MULTIINSTANCE_ONLY + + @property + def message(self) -> str: + resource_type = _type_to_string(self.resource_type, article=True) + return ( + "Can only restart on a specific node for a clone or bundle, " + f"'{self.resource}' is {resource_type}" + ) + + +@dataclass(frozen=True) +class ResourceRestartUsingParentRersource(ReportItemMessage): + """ + Multiinstance parent is restarted instead of a specified primitive + + resource -- resource which has been asked to be restarted + parent -- parent resource to be restarted instead + """ + + resource: str + parent: str + _code = codes.RESOURCE_RESTART_USING_PARENT_RESOURCE + + @property + def message(self) -> str: + return ( + f"Restarting '{self.parent}' instead...\n" + "(If a resource is a clone or bundle, you must use the clone or " + "bundle instead)" + ) @dataclass(frozen=True) @@ -3521,6 +3692,25 @@ def message(self) -> str: return f"{desc} '{self.node}' does not appear to exist in configuration" +@dataclass(frozen=True) +class NodeRenameNamesEqual(ReportItemMessage): + """ + Cannot rename node because old and new names are the same. + + name -- the node name + """ + + name: str + _code = codes.NODE_RENAME_NAMES_EQUAL + + @property + def message(self) -> str: + return ( + f"Unable to rename node '{self.name}': " + "new name is the same as the current name" + ) + + @dataclass(frozen=True) class NodeToClearIsStillInCluster(ReportItemMessage): """ @@ -3914,7 +4104,7 @@ def message(self) -> str: @dataclass(frozen=True) class AgentGenericError(ReportItemMessage): """ - Unspecifed error related to resource / fence agent + Unspecified error related to resource / fence agent agent -- name of the agent """ @@ -4840,10 +5030,12 @@ class LiveEnvironmentRequired(ReportItemMessage): @property def message(self) -> str: - return "This command does not support {forbidden_options}".format( - forbidden_options=format_list( - [str(item) for item in self.forbidden_options] - ), + return ( + "This command does not support passing {forbidden_options}".format( + forbidden_options=format_list( + [str(item) for item in self.forbidden_options] + ), + ) ) @@ -5014,9 +5206,9 @@ class CibFencingLevelAlreadyExists(ReportItemMessage): """ level: str - target_type: str - target_value: Optional[Tuple[str, str]] - devices: List[str] + target_type: FencingTargetType + target_value: FencingTargetValue + devices: list[str] _code = codes.CIB_FENCING_LEVEL_ALREADY_EXISTS @property @@ -5040,9 +5232,9 @@ class CibFencingLevelDoesNotExist(ReportItemMessage): """ level: str = "" - target_type: Optional[str] = None - target_value: Optional[Tuple[str, str]] = None - devices: List[str] = field(default_factory=list) + target_type: Optional[FencingTargetType] = None + target_value: Optional[FencingTargetValue] = None + devices: list[str] = field(default_factory=list) _code = codes.CIB_FENCING_LEVEL_DOES_NOT_EXIST @property @@ -5067,6 +5259,23 @@ def message(self) -> str: ) +@dataclass(frozen=True) +class CibRemoveResources(ReportItemMessage): + """ + Information about removal of resources from cib. + """ + + id_list: list[str] + _code = codes.CIB_REMOVE_RESOURCES + + @property + def message(self) -> str: + return "Removing {resource_pl}: {resource_list}".format( + resource_pl=format_plural(self.id_list, "resource"), + resource_list=format_list(self.id_list), + ) + + @dataclass(frozen=True) class CibRemoveDependantElements(ReportItemMessage): """ @@ -5093,6 +5302,47 @@ def _format_line(tag: str, ids: list[str]) -> str: return f"Removing dependant {element_pl}:\n{info_lines}" +@dataclass(frozen=True) +class CibRemoveReferences(ReportItemMessage): + """ + Information about removal of references from cib elements due to + dependencies. + """ + + id_tag_map: Mapping[str, str] + removing_references_from: Mapping[str, StringIterable] + + _code = codes.CIB_REMOVE_REFERENCES + + @property + def message(self) -> str: + id_tag_map = defaultdict(lambda: "element", self.id_tag_map) + + def _format_line(tag: str, ids: list[str]) -> str: + tag_desc = format_plural(ids, _type_to_string(tag)).capitalize() + id_list = format_list(ids) + return f" {tag_desc}: {id_list}" + + def _format_one_element(element_id: str, ids: StringIterable) -> str: + tag_ids_map = defaultdict(list) + for _id in ids: + tag_ids_map[id_tag_map[_id]].append(_id) + info_lines = "\n".join( + sorted( + [_format_line(tag, ids) for tag, ids in tag_ids_map.items()] + ) + ) + tag_desc = _type_to_string(id_tag_map[element_id]).capitalize() + return f" {tag_desc} '{element_id}' from:\n{info_lines}" + + lines = "\n".join( + _format_one_element(key, self.removing_references_from[key]) + for key in sorted(self.removing_references_from) + ) + + return f"Removing references:\n{lines}" + + @dataclass(frozen=True) class UseCommandNodeAddRemote(ReportItemMessage): """ @@ -5119,6 +5369,19 @@ def message(self) -> str: return "this command is not sufficient for creating a guest node" +@dataclass(frozen=True) +class UseCommandNodeRemoveRemote(ReportItemMessage): + """ + Advise the user for more appropriate command. + """ + + _code = codes.USE_COMMAND_NODE_REMOVE_REMOTE + + @property + def message(self) -> str: + return "this command is not sufficient for removing a remote node" + + @dataclass(frozen=True) class UseCommandNodeRemoveGuest(ReportItemMessage): """ @@ -5132,6 +5395,83 @@ def message(self) -> str: return "this command is not sufficient for removing a guest node" +@dataclass(frozen=True) +class RemoteNodeRemovalIncomplete(ReportItemMessage): + """ + Warn the user about needed manual steps after removal of remote node. + + node_name -- name of the remote node + """ + + node_name: str + _code = codes.REMOTE_NODE_REMOVAL_INCOMPLETE + + @property + def message(self) -> str: + return ( + "This command is not sufficient for removing remote node: " + "'{name}'. To complete the removal, remove pacemaker authkey and " + "stop and disable pacemaker_remote on the node manually." + ).format(name=self.node_name) + + +@dataclass(frozen=True) +class UseCommandRemoveAndAddGuestNode(ReportItemMessage): + """ + Changing connection parameters of an existing guest node is not recommended, + new guest nodes should be readded. + """ + + _code = codes.USE_COMMAND_REMOVE_AND_ADD_GUEST_NODE + + @property + def message(self) -> str: + return ( + "Changing connection parameters of an existing guest node is not " + "sufficient for connecting to a different guest node, remove the " + "existing guest node and add a new one instead" + ) + + +@dataclass(frozen=True) +class GuestNodeRemovalIncomplete(ReportItemMessage): + """ + Warn the user about needed manual steps after removal of guest node. + + node_name -- name of the guest node + """ + + node_name: str + _code = codes.GUEST_NODE_REMOVAL_INCOMPLETE + + @property + def message(self) -> str: + return ( + "This command is not sufficient for removing guest node: '{name}'. " + "To complete the removal, remove pacemaker authkey and stop and " + "disable pacemaker_remote on the node manually." + ).format(name=self.node_name) + + +@dataclass(frozen=True) +class GuestNodeNameAlreadyExists(ReportItemMessage): + """ + Cannot set a guest node name that overlaps with another id in the CIB. + + node_name -- node name conflicting with another id + """ + + node_name: str + _code = codes.GUEST_NODE_NAME_ALREADY_EXISTS + + @property + def message(self) -> str: + return ( + f"Cannot set name of the guest node to '{self.node_name}' because " + "that ID already exists in the cluster configuration." + ) + + @dataclass(frozen=True) class TmpFileWrite(ReportItemMessage): """ @@ -6202,6 +6542,89 @@ def message(self) -> str: return "You must specify a node when moving/banning a stopped resource" +@dataclass(frozen=True) +class StoppingResourcesBeforeDeleting(ReportItemMessage): + """ + Resources are going to be stopped before deletion + + resource_id_list -- ids of resources that are going to be stopped + """ + + resource_id_list: list[str] + _code = codes.STOPPING_RESOURCES_BEFORE_DELETING + + @property + def message(self) -> str: + return "Stopping {resource} {resource_list} before deleting".format( + resource=format_plural(self.resource_id_list, "resource"), + resource_list=format_list(self.resource_id_list), + ) + + +@dataclass(frozen=True) +class StoppingResourcesBeforeDeletingSkipped(ReportItemMessage): + """ + Resources are not going to be stopped before deletion. + """ + + _code = codes.STOPPING_RESOURCES_BEFORE_DELETING_SKIPPED + + @property + def message(self) -> str: + return ( + "Resources are not going to be stopped before deletion, this may " + "result in orphaned resources being present in the cluster" + ) + + +@dataclass(frozen=True) +class CannotStopResourcesBeforeDeleting(ReportItemMessage): + """ + Cannot stop resources that are being removed + + resource_id_list -- ids of resources that cannot be stopped + """ + + resource_id_list: list[str] + _code = codes.CANNOT_STOP_RESOURCES_BEFORE_DELETING + + @property + def message(self) -> str: + return "Cannot stop {resource} {resource_list} before deleting".format( + resource=format_plural(self.resource_id_list, "resource"), + resource_list=format_list(self.resource_id_list), + ) + + +@dataclass(frozen=True) +class ConfiguredResourceMissingInStatus(ReportItemMessage): + """ + Cannot check status of resource, because the resource is missing in cluster + status despite being configured in CIB. This happens for misconfigured + resources, e.g. bundle with primitive resource inside and no IP address + for the bundle specified. + + resource_id -- id of the resource + checked_state -- expected state of the resource + """ + + resource_id: str + checked_state: Optional[ResourceState] = None + _code = codes.CONFIGURED_RESOURCE_MISSING_IN_STATUS + + @property + def message(self) -> str: + return ( + "Cannot check if the resource '{resource_id}' is in expected " + "state{state}, since the resource is missing in cluster status" + ).format( + resource_id=self.resource_id, + state=format_optional( + self.checked_state and self.checked_state.name.lower(), " ({})" + ), + ) + + @dataclass(frozen=True) class ResourceBanPcmkError(ReportItemMessage): """ @@ -6755,6 +7178,22 @@ def message(self) -> str: return f"booth ticket name '{self.ticket_name}' does not exist" +@dataclass(frozen=True) +class BoothTicketNotInCib(ReportItemMessage): + """ + Expected ticket is not in CIB + + ticket_name -- name of the ticket + """ + + ticket_name: str + _code = codes.BOOTH_TICKET_NOT_IN_CIB + + @property + def message(self) -> str: + return f"Unable to find ticket '{self.ticket_name}' in CIB" + + @dataclass(frozen=True) class BoothAlreadyInCib(ReportItemMessage): """ @@ -7018,8 +7457,9 @@ def message(self) -> str: @dataclass(frozen=True) class BoothTicketOperationFailed(ReportItemMessage): """ - Pcs uses external booth tools for some ticket_name operations. For example - grand and revoke. But the external command failed. + Pcs uses external tools for some ticket_name operations. For example + booth tools are used for grant and revoke, or pacemaker tools are used for + standby and cleanup. But the external command failed. operation -- determine what was intended perform with ticket_name reason -- error description from external booth command @@ -7029,18 +7469,57 @@ class BoothTicketOperationFailed(ReportItemMessage): operation: str reason: str - site_ip: str + site_ip: Optional[str] ticket_name: str _code = codes.BOOTH_TICKET_OPERATION_FAILED @property def message(self) -> str: return ( - f"unable to {self.operation} booth ticket '{self.ticket_name}'" - f" for site '{self.site_ip}', reason: {self.reason}" + "unable to {operation} booth ticket '{ticket_name}'{site}, " + "reason: {reason}" + ).format( + operation=self.operation, + ticket_name=self.ticket_name, + reason=self.reason, + site=format_optional(self.site_ip, template=" for site '{}'"), ) +@dataclass(frozen=True) +class BoothTicketChangingState(ReportItemMessage): + """ + The state of the ticket is changing + + ticket_name -- name of the ticket + state -- new state of the ticket + """ + + ticket_name: str + state: Literal["active", "standby"] + _code = codes.BOOTH_TICKET_CHANGING_STATE + + @property + def message(self) -> str: + return f"Changing state of ticket '{self.ticket_name}' to {self.state}" + + +@dataclass(frozen=True) +class BoothTicketCleanup(ReportItemMessage): + """ + The booth ticket is going to be removed from CIB + + ticket_name -- name of the ticket + """ + + ticket_name: str + _code = codes.BOOTH_TICKET_CLEANUP + + @property + def message(self) -> str: + return f"Cleaning up ticket '{self.ticket_name}' from CIB" + + # TODO: remove, use ADD_REMOVE reports @dataclass(frozen=True) class TagAddRemoveIdsDuplication(ReportItemMessage): @@ -7861,3 +8340,246 @@ def message(self) -> str: f"'{self.nvset_id}' already exists. Find elements with the ID and " "remove them from cluster configuration." ) + + +@dataclass(frozen=True) +class DlmClusterRenameNeeded(ReportItemMessage): + """ + Dlm cluster name in volume group metadata must be updated + """ + + _code = codes.DLM_CLUSTER_RENAME_NEEDED + + @property + def message(self) -> str: + return ( + "The DLM cluster name in the shared volume groups metadata must be " + "updated to reflect the name of the cluster so that the volume " + "groups can start" + ) + + +@dataclass(frozen=True) +class Gfs2LockTableRenameNeeded(ReportItemMessage): + """ + Lock table name on each GFS2 filesystem must be updated + """ + + _code = codes.GFS2_LOCK_TABLE_RENAME_NEEDED + + @property + def message(self) -> str: + return ( + "The lock table name on each GFS2 filesystem must be updated to " + "reflect the name of the cluster so that the filesystems can be " + "mounted" + ) + + +@dataclass(frozen=True) +class CibClusterNameRemovalStarted(ReportItemMessage): + """ + Cluster name property is about to be deleted on nodes + """ + + _code = codes.CIB_CLUSTER_NAME_REMOVAL_STARTED + + @property + def message(self) -> str: + return "Removing CIB cluster name property on nodes..." + + +@dataclass(frozen=True) +class CibClusterNameRemoved(ReportItemMessage): + """ + Cluster name property has been deleted from CIB on node. + + node -- node address / name + """ + + node: str + _code = codes.CIB_CLUSTER_NAME_REMOVED + + @property + def message(self) -> str: + return f"{self.node}: Succeeded" + + +@dataclass(frozen=True) +class CibClusterNameRemovalFailed(ReportItemMessage): + """ + Cluster name property removal has failed + + reason -- description of the error + """ + + reason: str + _code = codes.CIB_CLUSTER_NAME_REMOVAL_FAILED + + @property + def message(self) -> str: + return f"CIB cluster name property removal failed: {self.reason}" + + +@dataclass(frozen=True) +class PacemakerRunning(ReportItemMessage): + """ + Pacemaker is running on a node. + """ + + _code = codes.PACEMAKER_RUNNING + + @property + def message(self) -> str: + return "Pacemaker is running" + + +@dataclass(frozen=True) +class CibXmlMissing(ReportItemMessage): + """ + CIB XML file cannot be found + """ + + _code = codes.CIB_XML_MISSING + + @property + def message(self) -> str: + return "CIB XML file cannot be found" + + +@dataclass(frozen=True) +class CibNodeRenameElementUpdated(ReportItemMessage): + """ + A CIB element was updated during node rename. + + element_type -- type of the updated element + element_id -- identifier of the updated element + attribute_desc -- description of the updated attribute + old_value -- original value + new_value -- new value + """ + + element_type: str + element_id: str + attribute_desc: str + old_value: str + new_value: str + _code = codes.CIB_NODE_RENAME_ELEMENT_UPDATED + + @property + def message(self) -> str: + return ( + f"{_type_to_string(self.element_type).capitalize()}" + f" '{self.element_id}': " + f"{self.attribute_desc} updated from " + f"'{self.old_value}' to '{self.new_value}'" + ) + + +@dataclass(frozen=True) +class CibNodeRenameFencingLevelPatternExists(ReportItemMessage): + """ + A fencing level uses a target-pattern, which may match node names + and needs manual review. + + level_id -- fencing level id + pattern -- the target-pattern value + """ + + level_id: str + pattern: str + _code = codes.CIB_NODE_RENAME_FENCING_LEVEL_PATTERN_EXISTS + + @property + def message(self) -> str: + return ( + f"Fencing level '{self.level_id}' uses target-pattern " + f"'{self.pattern}', which may match the renamed node, " + "check the pattern and adjust the configuration if necessary" + ) + + +@dataclass(frozen=True) +class CibNodeRenameAclsExist(ReportItemMessage): + """ + ACL rules exist in CIB and may contain references to node names + that need manual review. + """ + + _code = codes.CIB_NODE_RENAME_ACLS_EXIST + + @property + def message(self) -> str: + return ( + "ACL rules exist in CIB and may contain references to node names, " + "check the ACL configuration and adjust it if necessary" + ) + + +@dataclass(frozen=True) +class CibNodeRenameNewNodeNotInCorosync(ReportItemMessage): + """ + The new node name was not found among corosync.conf nodes. + + new_name -- the new node name that was not found + """ + + new_name: str + _code = codes.CIB_NODE_RENAME_NEW_NODE_NOT_IN_COROSYNC + + @property + def message(self) -> str: + return ( + f"Node '{self.new_name}' is not known to corosync, " + "the node name may be incorrect" + ) + + +@dataclass(frozen=True) +class CibNodeRenameOldNodeInCorosync(ReportItemMessage): + """ + The old node name was found in corosync.conf, indicating that the node + has not been renamed in corosync.conf yet. + + old_name -- the old node name that was found in corosync.conf + """ + + old_name: str + _code = codes.CIB_NODE_RENAME_OLD_NODE_IN_COROSYNC + + @property + def message(self) -> str: + return ( + f"Node '{self.old_name}' is still known to corosync, " + "the node may not have been renamed in corosync.conf yet" + ) + + +@dataclass(frozen=True) +class CibNodeRenameNoChange(ReportItemMessage): + """ + No CIB elements were updated during node rename operation. + """ + + _code = codes.CIB_NODE_RENAME_NO_CHANGE + + @property + def message(self) -> str: + return "No CIB configuration changes needed for node rename" + + +@dataclass(frozen=True) +class NoStonithMeansWouldBeLeft(ReportItemMessage): + """ + The requested change would left the cluster with no stonith configured + """ + + _code = codes.NO_STONITH_MEANS_WOULD_BE_LEFT + + @property + def message(self) -> str: + return ( + "Requested action lefts the cluster with no enabled means to fence " + "nodes, resulting in the cluster not being able to recover from " + "certain failure conditions" + ) diff --git a/pcs/common/reports/processor.py b/pcs/common/reports/processor.py index 0be230731..24dca4a0d 100644 --- a/pcs/common/reports/processor.py +++ b/pcs/common/reports/processor.py @@ -32,10 +32,7 @@ def _do_report(self, report_item: ReportItem) -> None: def has_errors(report_list: ReportItemList) -> bool: - for report_item in report_list: - if _is_error(report_item): - return True - return False + return any(_is_error(report_item) for report_item in report_list) def _is_error(report_item: ReportItem) -> bool: diff --git a/pcs/common/reports/types.py b/pcs/common/reports/types.py index 7fcc1e284..6513f191c 100644 --- a/pcs/common/reports/types.py +++ b/pcs/common/reports/types.py @@ -1,4 +1,4 @@ -from typing import NewType +from typing import Collection, NewType AddRemoveContainerType = NewType("AddRemoveContainerType", str) AddRemoveItemType = NewType("AddRemoveItemType", str) @@ -6,6 +6,7 @@ DefaultAddressSource = NewType("DefaultAddressSource", str) FenceHistoryCommandType = NewType("FenceHistoryCommandType", str) ForceCode = NewType("ForceCode", str) +ForceFlags = Collection[ForceCode] MessageCode = NewType("MessageCode", str) DeprecatedMessageCode = NewType("DeprecatedMessageCode", str) PcsCommand = NewType("PcsCommand", str) diff --git a/pcs/common/resource_status.py b/pcs/common/resource_status.py index d0b297dbb..b57a43814 100644 --- a/pcs/common/resource_status.py +++ b/pcs/common/resource_status.py @@ -1,18 +1,7 @@ from collections import defaultdict from dataclasses import dataclass -from enum import ( - Enum, - auto, -) -from typing import ( - Final, - Iterable, - Literal, - Optional, - Sequence, - Union, - cast, -) +from enum import Enum, auto +from typing import Final, Iterable, Literal, Optional, Sequence, Union, cast from pcs.common.const import ( PCMK_ROLE_STOPPED, @@ -64,31 +53,31 @@ class ResourceState(Enum): possible values for checking the state of the resource """ - STARTED: AttributeTuple = ("role", PCMK_STATUS_ROLE_STARTED) - STOPPED: AttributeTuple = ("role", PCMK_STATUS_ROLE_STOPPED) - PROMOTED: AttributeTuple = ("role", PCMK_STATUS_ROLE_PROMOTED) - UNPROMOTED: AttributeTuple = ("role", PCMK_STATUS_ROLE_UNPROMOTED) - STARTING: AttributeTuple = ("role", PCMK_STATUS_ROLE_STARTING) - STOPPING: AttributeTuple = ("role", PCMK_STATUS_ROLE_STOPPING) - MIGRATING: AttributeTuple = ("role", PCMK_STATUS_ROLE_MIGRATING) - PROMOTING: AttributeTuple = ("role", PCMK_STATUS_ROLE_PROMOTING) - DEMOTING: AttributeTuple = ("role", PCMK_STATUS_ROLE_DEMOTING) - MONITORING: AttributeTuple = ("pending", "Monitoring") - DISABLED: AttributeTuple = ("disabled", True) - ENABLED: AttributeTuple = ("disabled", False) - MANAGED: AttributeTuple = ("managed", True) - UNMANAGED: AttributeTuple = ("managed", False) - MAINTENANCE: AttributeTuple = ("maintenance", True) - FAILED: AttributeTuple = ("failed", True) - ACTIVE: AttributeTuple = ("active", True) - ORPHANED: AttributeTuple = ("orphaned", True) - BLOCKED: AttributeTuple = ("blocked", True) - FAILURE_IGNORED: AttributeTuple = ("failure_ignored", True) - PENDING: list[AttributeTuple] = [ + STARTED = ("role", PCMK_STATUS_ROLE_STARTED) + STOPPED = ("role", PCMK_STATUS_ROLE_STOPPED) + PROMOTED = ("role", PCMK_STATUS_ROLE_PROMOTED) + UNPROMOTED = ("role", PCMK_STATUS_ROLE_UNPROMOTED) + STARTING = ("role", PCMK_STATUS_ROLE_STARTING) + STOPPING = ("role", PCMK_STATUS_ROLE_STOPPING) + MIGRATING = ("role", PCMK_STATUS_ROLE_MIGRATING) + PROMOTING = ("role", PCMK_STATUS_ROLE_PROMOTING) + DEMOTING = ("role", PCMK_STATUS_ROLE_DEMOTING) + MONITORING = ("pending", "Monitoring") + DISABLED = ("disabled", True) + ENABLED = ("disabled", False) + MANAGED = ("managed", True) + UNMANAGED = ("managed", False) + MAINTENANCE = ("maintenance", True) + FAILED = ("failed", True) + ACTIVE = ("active", True) + ORPHANED = ("orphaned", True) + BLOCKED = ("blocked", True) + FAILURE_IGNORED = ("failure_ignored", True) + PENDING = [ ("role", set(PCMK_STATUS_ROLES_PENDING)), ("pending", "Monitoring"), ] - LOCKED_TO: AttributeTuple = ("locked_to", NOT_NONE) + LOCKED_TO = ("locked_to", NOT_NONE) ResourceStateExactCheck = Literal[ResourceState.LOCKED_TO] @@ -422,43 +411,62 @@ def _get_instances_for_state_check( return GroupInstances(cast(list[GroupStatusDto], instance_list)) - def _validate_members_quantifier( - self, - resource: CheckedResourceType, - members_quantifier: Optional[MoreChildrenQuantifierType], - ) -> None: - if members_quantifier is None: - return - - if isinstance(resource, GroupInstances): - return + def can_have_multiple_members( + self, resource_id: str, instance_id: Optional[str] = None + ) -> bool: + """ + Check if the resource with the given id can have multiple inner members. - if isinstance(resource, CloneStatusDto): - member_id_list = self.get_members(resource.resource_id, None) - if any( + resource_id -- id of the resource + instance_id -- id describing unique instance of cloned or bundled + resource + """ + resource_type = self.get_type(resource_id, instance_id) + return resource_type == ResourceType.GROUP or ( + resource_type == ResourceType.CLONE + and any( self.get_type(member_id, None) == ResourceType.GROUP - for member_id in member_id_list - ): - return + for member_id in self.get_members(resource_id, instance_id) + ) + ) - raise MembersQuantifierUnsupportedException() + def can_have_multiple_instances( + self, resource_id: str, instance_id: Optional[str] = None + ) -> bool: + """ + Check if the resource with the given id can have multiple instances. - def _validate_instance_quantifier( + resource_id -- id of the resource + instance_id -- id describing unique instance of cloned or bundled + resource + """ + resource_type = self.get_type(resource_id, instance_id) + return instance_id is None and ( + resource_type in (ResourceType.CLONE, ResourceType.BUNDLE) + or self.get_parent_clone_id(resource_id, None) is not None + or ( + resource_type == ResourceType.PRIMITIVE + and self.get_parent_bundle_id(resource_id, None) is not None + ) + ) + + def _validate_quantifiers( self, - resource: CheckedResourceType, + resource_id: str, + instance_id: Optional[str], + members_quantifier: Optional[MoreChildrenQuantifierType], instances_quantifier: Optional[MoreChildrenQuantifierType], ) -> None: - # pylint: disable=no-self-use - if instances_quantifier is None: - return - - if isinstance(resource, (BundleStatusDto, CloneStatusDto)): - return - - if len(resource.instances) > 1: - return - - raise InstancesQuantifierUnsupportedException() + if ( + members_quantifier is not None + and not self.can_have_multiple_members(resource_id, instance_id) + ): + raise MembersQuantifierUnsupportedException() + if ( + instances_quantifier is not None + and not self.can_have_multiple_instances(resource_id, instance_id) + ): + raise InstancesQuantifierUnsupportedException() def is_state( self, @@ -496,8 +504,9 @@ def is_state( """ resource = self._get_instances_for_state_check(resource_id, instance_id) - self._validate_members_quantifier(resource, members_quantifier) - self._validate_instance_quantifier(resource, instances_quantifier) + self._validate_quantifiers( + resource_id, instance_id, members_quantifier, instances_quantifier + ) if not isinstance(state.value, list): checked_state = [state.value] @@ -554,8 +563,9 @@ def is_state_exact_value( """ resource = self._get_instances_for_state_check(resource_id, instance_id) - self._validate_members_quantifier(resource, members_quantifier) - self._validate_instance_quantifier(resource, instances_quantifier) + self._validate_quantifiers( + resource_id, instance_id, members_quantifier, instances_quantifier + ) if not isinstance(state.value, list): checked_state = [state.value] @@ -722,20 +732,20 @@ def get_members( if isinstance(resource, CloneStatusDto): return list( - set( + { instance.resource_id for instance in resource.instances if not _is_orphaned(instance) - ) + } ) if isinstance(resource, BundleStatusDto): return list( - set( + { replica.member.resource_id for replica in resource.replicas if replica.member is not None - ) + } ) raise ResourceUnexpectedTypeException( @@ -1067,7 +1077,7 @@ def _is_orphaned(resource: Union[PrimitiveStatusDto, GroupStatusDto]) -> bool: def _filter_clone_orphans( - instance_list: Sequence[Union[PrimitiveStatusDto, GroupStatusDto]] + instance_list: Sequence[Union[PrimitiveStatusDto, GroupStatusDto]], ) -> list[Union[PrimitiveStatusDto, GroupStatusDto]]: return [ instance for instance in instance_list if not _is_orphaned(instance) diff --git a/pcs/common/services/common.py b/pcs/common/services/common.py index d8a46f7e2..d24062483 100644 --- a/pcs/common/services/common.py +++ b/pcs/common/services/common.py @@ -1,2 +1,2 @@ # pylint: disable=unused-import -from pcs.common.str_tools import join_multilines +from pcs.common.str_tools import join_multilines # noqa: F401 diff --git a/pcs/common/services/drivers/sysvinit_rhel.py b/pcs/common/services/drivers/sysvinit_rhel.py index 203f7c807..36948cab1 100644 --- a/pcs/common/services/drivers/sysvinit_rhel.py +++ b/pcs/common/services/drivers/sysvinit_rhel.py @@ -29,21 +29,25 @@ def __init__( self._available_services: List[str] = [] def start(self, service: str, instance: Optional[str] = None) -> None: + del instance result = self._executor.run([self._service_bin, service, "start"]) if result.retval != 0: raise errors.StartServiceError(service, result.joined_output) def stop(self, service: str, instance: Optional[str] = None) -> None: + del instance result = self._executor.run([self._service_bin, service, "stop"]) if result.retval != 0: raise errors.StopServiceError(service, result.joined_output) def enable(self, service: str, instance: Optional[str] = None) -> None: + del instance result = self._executor.run([self._chkconfig_bin, service, "on"]) if result.retval != 0: raise errors.EnableServiceError(service, result.joined_output) def disable(self, service: str, instance: Optional[str] = None) -> None: + del instance if not self.is_installed(service): return result = self._executor.run([self._chkconfig_bin, service, "off"]) @@ -51,9 +55,11 @@ def disable(self, service: str, instance: Optional[str] = None) -> None: raise errors.DisableServiceError(service, result.joined_output) def is_enabled(self, service: str, instance: Optional[str] = None) -> bool: + del instance return self._executor.run([self._chkconfig_bin, service]).retval == 0 def is_running(self, service: str, instance: Optional[str] = None) -> bool: + del instance return ( self._executor.run([self._service_bin, service, "status"]).retval == 0 @@ -73,8 +79,8 @@ def _get_available_services(self) -> List[str]: return [] service_list = [] - for service in result.stdout.splitlines(): - service = service.split(" ", 1)[0] + for service_line in result.stdout.splitlines(): + service = service_line.split(" ", 1)[0] if service: service_list.append(service) return service_list diff --git a/pcs/common/str_tools.py b/pcs/common/str_tools.py index 2afbe4f10..5b2ee3625 100644 --- a/pcs/common/str_tools.py +++ b/pcs/common/str_tools.py @@ -1,3 +1,4 @@ +import contextlib from collections.abc import Iterable as IterableAbc from collections.abc import Sized from typing import ( @@ -89,9 +90,9 @@ def format_name_value_list(item_list: Sequence[tuple[str, str]]) -> list[str]: Turn 2-tuples to 'name=value' strings with standard quoting """ output = [] - for name, value in item_list: - name = quote(name, "= ") - value = quote(value, "= ") + for raw_name, raw_value in item_list: + name = quote(raw_name, "= ") + value = quote(raw_value, "= ") output.append(f"{name}={value}") return output @@ -99,15 +100,15 @@ def format_name_value_list(item_list: Sequence[tuple[str, str]]) -> list[str]: # For now, tuple[str, str, str] is sufficient. Feel free to change it if # needed, e.g. when values can be integers. def format_name_value_id_list( - item_list: Sequence[tuple[str, str, str]] + item_list: Sequence[tuple[str, str, str]], ) -> list[str]: """ Turn 3-tuples to 'name=value (id: id))' strings with standard quoting """ output = [] - for name, value, an_id in item_list: - name = quote(name, "= ") - value = quote(value, "= ") + for raw_name, raw_value, an_id in item_list: + name = quote(raw_name, "= ") + value = quote(raw_value, "= ") output.append(f"{name}={value} (id: {an_id})") return output @@ -119,16 +120,16 @@ def pairs_to_text(pairs: Sequence[tuple[str, str]]) -> list[str]: def format_name_value_default_list( - item_list: Sequence[tuple[str, str, bool]] + item_list: Sequence[tuple[str, str, bool]], ) -> list[str]: """ Turn 3-tuples to 'name=value' or 'name=value (default)' strings with standard quoting """ output = [] - for name, value, is_default in item_list: - name = quote(name, "= ") - value = quote(value, "= ") + for raw_name, raw_value, is_default in item_list: + name = quote(raw_name, "= ") + value = quote(raw_value, "= ") default = " (default)" if is_default else "" output.append(f"{name}={value}{default}") return output @@ -185,10 +186,8 @@ def _is_multiple(what: Union[int, Sized]) -> bool: if isinstance(what, int): retval = abs(what) != 1 elif not isinstance(what, str): - try: + with contextlib.suppress(TypeError): retval = len(what) != 1 - except TypeError: - pass return retval @@ -245,7 +244,7 @@ def format_plural( def transform(items: list[T], mapping: Mapping[T, str]) -> list[str]: - return list(map(lambda item: mapping.get(item, str(item)), items)) + return [mapping.get(item, str(item)) for item in items] def is_iterable_not_str(value: Union[IterableAbc, str]) -> bool: diff --git a/pcs/common/tools.py b/pcs/common/tools.py index 5ffe7af1f..9dca2917c 100644 --- a/pcs/common/tools.py +++ b/pcs/common/tools.py @@ -1,25 +1,12 @@ -import threading import uuid -from dataclasses import ( - astuple, - dataclass, -) -from typing import ( - Any, - Callable, - Generator, - Iterable, - Mapping, - MutableSet, - Optional, - TypeVar, - Union, -) +from dataclasses import astuple, dataclass +from typing import Generator, MutableSet, Optional, TypeVar, Union from lxml import etree from lxml.etree import _Element from pcs.common.types import StringCollection +from pcs.common.validate import is_integer T = TypeVar("T", bound=type) @@ -43,23 +30,12 @@ def get_unique_uuid(already_used: StringCollection) -> str: return candidate -def run_parallel( - worker: Callable[..., Any], - data_list: tuple[Iterable[Any], Mapping[str, Any]], -) -> None: - thread_list = [] - for args, kwargs in data_list: - thread = threading.Thread(target=worker, args=args, kwargs=kwargs) - thread.daemon = True - thread_list.append(thread) - thread.start() - - for thread in thread_list: - thread.join() - - def format_os_error(e: OSError) -> str: - return f"{e.strerror}: '{e.filename}'" if e.filename else e.strerror + if e.filename: + return f"{e.strerror}: '{e.filename}'" + if e.strerror: + return e.strerror + return (f"{e.__class__.__name__} {e}").strip() def xml_fromstring(xml: str) -> _Element: @@ -103,8 +79,10 @@ def timeout_to_seconds(timeout: Union[int, str]) -> Optional[int]: "hr": 3600, } for suffix, multiplier in suffix_multiplier.items(): - if timeout.endswith(suffix) and timeout[: -len(suffix)].isdigit(): - return int(timeout[: -len(suffix)]) * multiplier + if timeout.endswith(suffix): + candidate2 = timeout[: -len(suffix)] + if is_integer(candidate2, at_least=0): + return int(candidate2) * multiplier return None @@ -140,6 +118,12 @@ def __lt__(self, other: "Version") -> bool: def __le__(self, other: "Version") -> bool: return self.as_full_tuple <= other.as_full_tuple + def __hash__(self) -> int: + # https://docs.astral.sh/ruff/rules/eq-without-hash/ + # used self.as_full_tuple because __eq__ and __hash__ should be in sync, + # objects which compare equal must have the same hash value + return hash(self.as_full_tuple) + # See, https://stackoverflow.com/questions/37557411/why-does-defining-the-argument-types-for-eq-throw-a-mypy-type-error def __eq__(self, other: object) -> bool: if not isinstance(other, Version): diff --git a/pcs/common/types.py b/pcs/common/types.py index d97dd9ab1..41f14ff32 100644 --- a/pcs/common/types.py +++ b/pcs/common/types.py @@ -95,7 +95,7 @@ def from_str(cls, transport: str) -> "CorosyncTransportType": raise UnknownCorosyncTransportTypeException(transport) from None -class CorosyncNodeAddressType(Enum): +class CorosyncNodeAddressType(str, Enum): IPV4 = "IPv4" IPV6 = "IPv6" FQDN = "FQDN" diff --git a/pcs/common/validate.py b/pcs/common/validate.py index 4c695fd7a..9205d9220 100644 --- a/pcs/common/validate.py +++ b/pcs/common/validate.py @@ -19,6 +19,11 @@ def is_integer( at_least -- minimal allowed value at_most -- maximal allowed value """ + # Using str.isnumeric(), str.isdigit() or str.isdecimal() is not good + # enough, as they return True for unicode characters which cannot be + # processed by int() and turned to an integer. + # Using int() to check a string is not enough, because it allows whitespace + # in the value. try: if value is None or isinstance(value, float): return False diff --git a/pcs/config.py b/pcs/config.py index 1146b3803..fac5cc2f3 100644 --- a/pcs/config.py +++ b/pcs/config.py @@ -16,15 +16,14 @@ from xml.dom.minidom import parse from pcs import ( - alert, cluster, quorum, settings, status, - stonith, usage, utils, ) +from pcs.cli.alert.output import config_dto_to_lines as alerts_to_lines from pcs.cli.cluster_property.output import ( PropertyConfigurationFacade, properties_to_text, @@ -46,6 +45,8 @@ ResourcesConfigurationFacade, resources_to_text, ) +from pcs.cli.stonith.levels.output import stonith_level_config_to_text +from pcs.cli.tag.output import tags_to_text from pcs.common.interface import dto from pcs.common.pacemaker.constraint import CibConstraintsDto from pcs.common.str_tools import indent @@ -108,7 +109,7 @@ def config_show(lib, argv, modifiers): print("\n".join(indent(quorum_lines))) -def _config_show_cib_lines(lib, properties_facade=None): +def _config_show_cib_lines(lib, properties_facade=None): # noqa: PLR0912, PLR0915 """ Commandline options: * -f - CIB file @@ -151,8 +152,8 @@ def _config_show_cib_lines(lib, properties_facade=None): all_lines.append("Stonith Devices:") all_lines.extend(stonith_lines) - levels_lines = stonith.stonith_level_config_to_str( - lib.fencing_topology.get_config() + levels_lines = stonith_level_config_to_text( + lib.fencing_topology.get_config_dto() ) if levels_lines: if all_lines: @@ -174,10 +175,11 @@ def _config_show_cib_lines(lib, properties_facade=None): all_lines.append("") all_lines.extend(constraints_lines) - alert_lines = alert.alert_config_lines(lib) + alert_lines = indent(alerts_to_lines(lib.alert.get_config_dto())) if alert_lines: if all_lines: all_lines.append("") + all_lines.append("Alerts:") all_lines.extend(alert_lines) resources_defaults_lines = indent( @@ -220,15 +222,11 @@ def _config_show_cib_lines(lib, properties_facade=None): all_lines.append("") all_lines.extend(properties_lines) - tags = lib.tag.config([]) - if tags: + tag_lines = smart_wrap_text(tags_to_text(lib.tag.get_config_dto([]))) + if tag_lines: if all_lines: all_lines.append("") all_lines.append("Tags:") - tag_lines = [] - for tag in tags: - tag_lines.append(tag["tag_id"]) - tag_lines.extend(indent(tag["idref_list"])) all_lines.extend(indent(tag_lines, indent_step=1)) return all_lines @@ -333,7 +331,7 @@ def config_restore(lib, argv, modifiers): sys.exit(exitcode) -def config_restore_remote(infile_name, infile_obj): +def config_restore_remote(infile_name, infile_obj): # noqa: PLR0912 """ Commandline options: * --request-timeout - timeout for HTTP requests @@ -422,7 +420,7 @@ def config_restore_remote(infile_name, infile_obj): utils.err("unable to restore all nodes\n" + "\n".join(error_list)) -def config_restore_local(infile_name, infile_obj): +def config_restore_local(infile_name, infile_obj): # noqa: PLR0912, PLR0915 """ Commandline options: no options """ @@ -490,7 +488,7 @@ def config_restore_local(infile_name, infile_obj): if not extract_info: continue path_full = None - if hasattr(extract_info.get("pre_store_call"), "__call__"): + if callable(extract_info.get("pre_store_call")): extract_info["pre_store_call"]() if "rename" in extract_info and extract_info["rename"]: if tmp_dir is None: @@ -570,7 +568,7 @@ def config_backup_path_list(with_uid_gid=False): pcmk_authkey_attrs = dict(cib_attrs) pcmk_authkey_attrs["mode"] = 0o440 - file_list = { + return { "cib.xml": { "path": os.path.join(settings.cib_dir, "cib.xml"), "required": True, @@ -614,7 +612,6 @@ def config_backup_path_list(with_uid_gid=False): }, }, } - return file_list def _get_uid(user_name): diff --git a/pcs/constraint.py b/pcs/constraint.py index f239b62e2..ffb32b06a 100644 --- a/pcs/constraint.py +++ b/pcs/constraint.py @@ -23,6 +23,7 @@ from pcs.cli.constraint.location import command as location_command from pcs.cli.constraint.output import ( CibConstraintLocationAnyDto, + filter_constraints_by_rule_expired_status, location, print_config, ) @@ -46,6 +47,7 @@ get_all_constraints_ids, ) from pcs.common.pacemaker.resource.list import CibResourcesDto +from pcs.common.pacemaker.types import CibResourceDiscovery from pcs.common.reports import ReportItem from pcs.common.str_tools import ( format_list, @@ -91,10 +93,7 @@ class CrmRuleReturnCode(Enum): def constraint_location_cmd(lib, argv, modifiers): - if not argv: - sub_cmd = "config" - else: - sub_cmd = argv.pop(0) + sub_cmd = "config" if not argv else argv.pop(0) try: if sub_cmd == "add": @@ -119,10 +118,7 @@ def constraint_location_cmd(lib, argv, modifiers): def constraint_order_cmd(lib, argv, modifiers): - if not argv: - sub_cmd = "config" - else: - sub_cmd = argv.pop(0) + sub_cmd = "config" if not argv else argv.pop(0) try: if sub_cmd == "set": @@ -188,7 +184,7 @@ def _validate_resources_not_in_same_group(cib_dom, resource1, resource2): # with [score] [options] # with [score] [options] # with [score] [options] -def colocation_add(lib, argv, modifiers): +def colocation_add(lib, argv, modifiers): # noqa: PLR0912, PLR0915 """ Options: * -f - CIB file @@ -233,7 +229,7 @@ def _validate_and_prepare_role(role): role, format_list(const.PCMK_ROLES) ) ) - utils.print_depracation_warning_for_legacy_roles(role) + utils.print_deprecation_warning_for_legacy_roles(role) return pacemaker.role.get_value_for_cib( role_cleaned, new_roles_supported ) @@ -260,14 +256,11 @@ def _validate_and_prepare_role(role): if not argv: raise CmdLineInputError() - if len(argv) == 1: + if len(argv) == 1 or utils.is_score_or_opt(argv[1]): resource2 = argv.pop(0) else: - if utils.is_score_or_opt(argv[1]): - resource2 = argv.pop(0) - else: - role2 = _validate_and_prepare_role(argv.pop(0)) - resource2 = argv.pop(0) + role2 = _validate_and_prepare_role(argv.pop(0)) + resource2 = argv.pop(0) score, nv_pairs = _parse_score_options(argv) @@ -481,7 +474,7 @@ def order_start(lib, argv, modifiers): _order_add(resource1, resource2, order_options, modifiers) -def _order_add(resource1, resource2, options_list, modifiers): +def _order_add(resource1, resource2, options_list, modifiers): # noqa: PLR0912, PLR0915 """ Commandline options: * -f - CIB file @@ -780,6 +773,7 @@ def location_config_cmd( """ modifiers.ensure_only_supported("-f", "--output-format", "--full", "--all") filter_type: Optional[str] = None + filter_items: parse_args.Argv = [] if argv: filter_type, *filter_items = argv allowed_types = ("resources", "nodes") @@ -794,9 +788,9 @@ def location_config_cmd( "with grouping and filtering by nodes or resources" ) - constraints_dto = cast( - CibConstraintsDto, + constraints_dto = filter_constraints_by_rule_expired_status( lib.constraint.get_config(evaluate_rules=True), + modifiers.is_specified("--all"), ) constraints_dto = CibConstraintsDto( @@ -873,7 +867,9 @@ def _verify_score(score): ) -def location_prefer(lib, argv, modifiers): +def location_prefer( # noqa: PLR0912 + lib: Any, argv: parse_args.Argv, modifiers: parse_args.InputModifiers +) -> None: """ Options: * --force - allow unknown options, allow constraint for any resource type @@ -897,6 +893,7 @@ def location_prefer(lib, argv, modifiers): raise CmdLineInputError() skip_node_check = False + existing_nodes: list[str] = [] if modifiers.is_specified("-f") or modifiers.get("--force"): skip_node_check = True warn(LOCATION_NODE_VALIDATION_SKIP_MSG) @@ -917,18 +914,12 @@ def location_prefer(lib, argv, modifiers): if not skip_node_check: report_list += _verify_node_name(node, existing_nodes) if len(nodeconf_a) == 1: - if prefer: - score = "INFINITY" - else: - score = "-INFINITY" + score = "INFINITY" if prefer else "-INFINITY" else: score = nodeconf_a[1] _verify_score(score) if not prefer: - if score[0] == "-": - score = score[1:] - else: - score = "-" + score + score = score[1:] if score[0] == "-" else "-" + score parameters_list.append( [ @@ -948,7 +939,12 @@ def location_prefer(lib, argv, modifiers): location_add(lib, parameters, modifiers, skip_score_and_node_check=True) -def location_add(lib, argv, modifiers, skip_score_and_node_check=False): +def location_add( # noqa: PLR0912, PLR0915 + lib: Any, + argv: parse_args.Argv, + modifiers: parse_args.InputModifiers, + skip_score_and_node_check: bool = False, +) -> None: """ Options: * --force - allow unknown options, allow constraint for any resource type @@ -972,7 +968,29 @@ def location_add(lib, argv, modifiers, skip_score_and_node_check=False): if argv: for arg in argv: if "=" in arg: - options.append(arg.split("=", 1)) + name, value = arg.split("=", 1) + if name == "resource-discovery": + if not modifiers.get("--force"): + allowed_discovery = list( + map( + str, + [ + CibResourceDiscovery.ALWAYS, + CibResourceDiscovery.EXCLUSIVE, + CibResourceDiscovery.NEVER, + ], + ) + ) + if value not in allowed_discovery: + utils.err( + ( + "invalid {0} value '{1}', allowed values are: " + "{2}, use --force to override" + ).format( + name, value, format_list(allowed_discovery) + ) + ) + options.append([name, value]) else: raise CmdLineInputError(f"bad option '{arg}'") if options[-1][0] != "resource-discovery" and not modifiers.get( @@ -1015,24 +1033,26 @@ def location_add(lib, argv, modifiers, skip_score_and_node_check=False): # Verify current constraint doesn't already exist # If it does we replace it with the new constraint dummy_dom, constraintsElement = getCurrentConstraints(dom) - elementsToRemove = [] # If the id matches, or the rsc & node match, then we replace/remove - for rsc_loc in constraintsElement.getElementsByTagName("rsc_location"): + elementsToRemove = [ + rsc_loc + for rsc_loc in constraintsElement.getElementsByTagName("rsc_location") # pylint: disable=too-many-boolean-expressions - if rsc_loc.getAttribute("id") == constraint_id or ( + if rsc_loc.getAttribute("id") == constraint_id + or ( rsc_loc.getAttribute("node") == node and ( ( - RESOURCE_TYPE_RESOURCE == rsc_type + rsc_type == RESOURCE_TYPE_RESOURCE and rsc_loc.getAttribute("rsc") == rsc_value ) or ( - RESOURCE_TYPE_REGEXP == rsc_type + rsc_type == RESOURCE_TYPE_REGEXP and rsc_loc.getAttribute("rsc-pattern") == rsc_value ) ) - ): - elementsToRemove.append(rsc_loc) + ) + ] for etr in elementsToRemove: constraintsElement.removeChild(etr) @@ -1051,7 +1071,7 @@ def location_add(lib, argv, modifiers, skip_score_and_node_check=False): utils.replace_cib_configuration(dom) -def location_rule(lib, argv, modifiers): +def location_rule(lib, argv, modifiers): # noqa: PLR0912 """ Options: * -f - CIB file @@ -1111,6 +1131,28 @@ def location_rule(lib, argv, modifiers): # If resource-discovery is specified, we use it with the rsc_location # element not the rule if resource_discovery: + if not modifiers.get("--force"): + allowed_discovery = list( + map( + str, + [ + CibResourceDiscovery.ALWAYS, + CibResourceDiscovery.EXCLUSIVE, + CibResourceDiscovery.NEVER, + ], + ) + ) + if resource_discovery not in allowed_discovery: + utils.err( + ( + "invalid {0} value '{1}', allowed values are: {2}, " + "use --force to override" + ).format( + "resource-discovery", + resource_discovery, + format_list(allowed_discovery), + ) + ) lc.setAttribute("resource-discovery", options.pop("resource-discovery")) constraints.appendChild(lc) @@ -1154,12 +1196,14 @@ def location_rule_check_duplicates(dom, constraint_el, force): lines = [] for dup in duplicates: lines.append(" Constraint: %s" % dup.getAttribute("id")) - for dup_rule in utils.dom_get_children_by_tag_name(dup, "rule"): - lines.append( - rule_utils.ExportDetailed().get_string( - dup_rule, False, True, indent=" " - ) + lines.extend( + rule_utils.ExportDetailed().get_string( + dup_rule, False, True, indent=" " + ) + for dup_rule in utils.dom_get_children_by_tag_name( + dup, "rule" ) + ) utils.err( "duplicate constraint already exists, use --force to override\n" + "\n".join(lines) @@ -1219,7 +1263,7 @@ def getCurrentConstraints(passed_dom=None): # If returnStatus is set, then we don't error out, we just print the error # and return false -def constraint_rm( +def constraint_rm( # noqa: PLR0912 lib, argv, modifiers, @@ -1249,12 +1293,11 @@ def constraint_rm( c_id = argv.pop(0) elementFound = False - + dom = None + use_cibadmin = False if not constraintsElement: (dom, constraintsElement) = getCurrentConstraints(passed_dom) use_cibadmin = True - else: - use_cibadmin = False for co in constraintsElement.childNodes[:]: if co.nodeType != xml.dom.Node.ELEMENT_NODE: @@ -1419,25 +1462,30 @@ def remove_constraints_containing( # is empty, then we remove the set, if the parent of the set # is empty then we remove it if set_c.getAttribute("id") == resource_id: - pn = set_c.parentNode - pn.removeChild(set_c) + parent_node = set_c.parentNode + parent_node.removeChild(set_c) if output: print_to_stderr( "Removing {} from set {}".format( - resource_id, pn.getAttribute("id") + resource_id, parent_node.getAttribute("id") ) ) - if pn.getElementsByTagName("resource_ref").length == 0: + if parent_node.getElementsByTagName("resource_ref").length == 0: print_to_stderr( - "Removing set {}".format(pn.getAttribute("id")) + "Removing set {}".format(parent_node.getAttribute("id")) ) - pn2 = pn.parentNode - pn2.removeChild(pn) - if pn2.getElementsByTagName("resource_set").length == 0: - pn2.parentNode.removeChild(pn2) + parent_node_2 = parent_node.parentNode + parent_node_2.removeChild(parent_node) + if ( + parent_node_2.getElementsByTagName( + "resource_set" + ).length + == 0 + ): + parent_node_2.parentNode.removeChild(parent_node_2) print_to_stderr( "Removing constraint {}".format( - pn2.getAttribute("id") + parent_node_2.getAttribute("id") ) ) if passed_dom: @@ -1446,30 +1494,6 @@ def remove_constraints_containing( return None -def remove_constraints_containing_node(dom, node, output=False): - """ - Commandline options: no options - """ - for constraint in find_constraints_containing_node(dom, node): - if output: - print_to_stderr( - "Removing Constraint - {}".format(constraint.getAttribute("id")) - ) - constraint.parentNode.removeChild(constraint) - return dom - - -def find_constraints_containing_node(dom, node): - """ - Commandline options: no options - """ - return [ - constraint - for constraint in dom.getElementsByTagName("rsc_location") - if constraint.getAttribute("node") == node - ] - - # Re-assign any constraints referencing a resource to its parent (a clone # or master) def constraint_resource_update(old_id, dom): @@ -1499,6 +1523,13 @@ def constraint_rule_add(lib, argv, modifiers): * -f - CIB file * --force - allow duplicate constraints """ + # deprecated in pacemaker 2, removed in pacemaker 3 + # added to pcs after 0.11.8 + # the whole command removed in pcs-0.12 + deprecation_warning( + "The possibility of defining multiple rules in a single location " + "constraint is deprecated and will be removed." + ) del lib modifiers.ensure_only_supported("-f", "--force") if not argv: diff --git a/pcs/daemon/app/api_v1.py b/pcs/daemon/app/api_v1.py index 8e8b8804f..5a5b265d1 100644 --- a/pcs/daemon/app/api_v1.py +++ b/pcs/daemon/app/api_v1.py @@ -59,6 +59,7 @@ "cluster-remove-nodes/v1": "cluster.remove_nodes", "cluster-setup/v1": "cluster.setup", "cluster-generate-cluster-uuid/v1": "cluster.generate_cluster_uuid", + "cluster-property-remove-name/v1": "cluster_property.remove_cluster_name", "constraint-colocation-create-with-set/v1": "constraint.colocation.create_with_set", "constraint-order-create-with-set/v1": "constraint.order.create_with_set", "constraint-ticket-create-with-set/v1": "constraint.ticket.create_with_set", @@ -101,6 +102,7 @@ "sbd-enable-sbd/v1": "sbd.enable_sbd", "scsi-unfence-node/v2": "scsi.unfence_node", "scsi-unfence-node-mpath/v1": "scsi.unfence_node_mpath", + "status-full-cluster-status-plaintext/v1": "status.full_cluster_status_plaintext", # deprecated, use resource-agent-get-agent-metadata/v1 instead "stonith-agent-describe-agent/v1": "stonith_agent.describe_agent", # deprecated, use resource-agent-get-agents-list/v1 instead @@ -170,6 +172,7 @@ def send_response( self.finish(json.dumps(to_dict(response))) def write_error(self, status_code: int, **kwargs: Any) -> None: + del status_code response = communication.dto.InternalCommunicationResultDto( status=communication.const.COM_STATUS_EXCEPTION, status_msg=None, @@ -301,13 +304,13 @@ async def get(self) -> None: class ClusterStatusLegacyHandler(LegacyApiV1Handler): @staticmethod def _get_cmd() -> str: - return "status.full_cluster_status_plaintext" + return "status-full-cluster-status-plaintext/v1" class ClusterAddNodesLegacyHandler(LegacyApiV1Handler): @staticmethod def _get_cmd() -> str: - return "cluster.add_nodes" + return "cluster-add-nodes/v1" def get_routes(scheduler: Scheduler, auth_provider: AuthProvider) -> RoutesType: diff --git a/pcs/daemon/app/auth.py b/pcs/daemon/app/auth.py index 559ae9c8b..a7783912f 100644 --- a/pcs/daemon/app/auth.py +++ b/pcs/daemon/app/auth.py @@ -74,8 +74,8 @@ def get_unix_socket_user(self) -> Optional[str]: if not self.is_unix_socket_used(): return None - # It is not cached to prevent inapropriate cache when handler is (in - # hypotetical future) somehow reused. The responsibility for cache is + # It is not cached to prevent inappropriate cache when handler is (in + # hypothetical future) somehow reused. The responsibility for cache is # left to the place, that uses it. # For whatever reason, handler.request.connection is typed as # HTTPConnection in tornado. That class, however, doesn't have stream diff --git a/pcs/daemon/app/common.py b/pcs/daemon/app/common.py index 7dfc2c000..f2b6284a6 100644 --- a/pcs/daemon/app/common.py +++ b/pcs/daemon/app/common.py @@ -8,9 +8,9 @@ from tornado.web import ( Finish, HTTPError, + RequestHandler, ) from tornado.web import RedirectHandler as TornadoRedirectHandler -from tornado.web import RequestHandler RoutesType = Iterable[ tuple[str, Type[RequestHandler], Optional[dict[str, Any]]] diff --git a/pcs/daemon/async_tasks/scheduler.py b/pcs/daemon/async_tasks/scheduler.py index e3645c86a..975578aad 100644 --- a/pcs/daemon/async_tasks/scheduler.py +++ b/pcs/daemon/async_tasks/scheduler.py @@ -209,8 +209,8 @@ def _spawn_new_single_use_worker(self) -> None: group=None, target=mp_worker_init, args=( - self._proc_pool._inqueue, # type: ignore - self._proc_pool._outqueue, # type: ignore + self._proc_pool._inqueue, # type: ignore # noqa: SLF001 + self._proc_pool._outqueue, # type: ignore # noqa: SLF001 worker_init, (self._worker_message_q, self._logging_q), 1, diff --git a/pcs/daemon/async_tasks/worker/command_mapping.py b/pcs/daemon/async_tasks/worker/command_mapping.py index 8819744ad..4f29789ba 100644 --- a/pcs/daemon/async_tasks/worker/command_mapping.py +++ b/pcs/daemon/async_tasks/worker/command_mapping.py @@ -8,6 +8,7 @@ from pcs.lib.commands import ( # services, acl, alert, + booth, cib, cib_options, cluster, @@ -24,6 +25,7 @@ status, stonith, stonith_agent, + tag, ) from pcs.lib.permissions.config.types import PermissionAccessType as p @@ -91,6 +93,10 @@ class _Cmd: cmd=alert.create_alert, required_permission=p.WRITE, ), + "alert.get_config_dto": _Cmd( + cmd=alert.get_config_dto, + required_permission=p.READ, + ), "alert.remove_alert": _Cmd( cmd=alert.remove_alert, required_permission=p.WRITE, @@ -107,6 +113,18 @@ class _Cmd: cmd=alert.update_recipient, required_permission=p.WRITE, ), + "booth.ticket_cleanup": _Cmd( + cmd=booth.ticket_cleanup, + required_permission=p.WRITE, + ), + "booth.ticket_standby": _Cmd( + cmd=booth.ticket_standby, + required_permission=p.WRITE, + ), + "booth.ticket_unstandby": _Cmd( + cmd=booth.ticket_unstandby, + required_permission=p.WRITE, + ), "cluster.add_nodes": _Cmd( cmd=cluster.add_nodes, required_permission=p.FULL, @@ -115,6 +133,10 @@ class _Cmd: cmd=cluster.generate_cluster_uuid, required_permission=p.SUPERUSER, ), + "cluster.get_corosync_conf_struct": _Cmd( + cmd=cluster.get_corosync_conf_struct, + required_permission=p.READ, + ), "cluster.node_clear": _Cmd( cmd=cluster.node_clear, required_permission=p.WRITE, @@ -123,6 +145,18 @@ class _Cmd: cmd=cluster.remove_nodes, required_permission=p.FULL, ), + "cluster.rename": _Cmd( + cmd=cluster.rename, + required_permission=p.FULL, + ), + "cluster.rename_node_cib": _Cmd( + cmd=cluster.rename_node_cib, + required_permission=p.WRITE, + ), + "cluster.rename_node_corosync": _Cmd( + cmd=cluster.rename_node_corosync, + required_permission=p.WRITE, + ), "cluster.setup": _Cmd( cmd=cluster.setup, required_permission=p.SUPERUSER, @@ -139,6 +173,10 @@ class _Cmd: cmd=cluster_property.set_properties, required_permission=p.WRITE, ), + "cluster_property.remove_cluster_name": _Cmd( + cmd=cluster_property.remove_cluster_name, + required_permission=p.WRITE, + ), "cluster.wait_for_pcmk_idle": _Cmd( cmd=cluster.wait_for_pcmk_idle, required_permission=p.READ, @@ -183,6 +221,10 @@ class _Cmd: cmd=fencing_topology.add_level, required_permission=p.WRITE, ), + "fencing_topology.get_config_dto": _Cmd( + cmd=fencing_topology.get_config_dto, + required_permission=p.READ, + ), "fencing_topology.remove_all_levels": _Cmd( cmd=fencing_topology.remove_all_levels, required_permission=p.WRITE, @@ -317,6 +359,10 @@ class _Cmd: cmd=resource.enable, required_permission=p.WRITE, ), + "resource.get_configured_resources": _Cmd( + cmd=resource.get_configured_resources, + required_permission=p.READ, + ), "resource.group_add": _Cmd( cmd=resource.group_add, required_permission=p.WRITE, @@ -325,6 +371,10 @@ class _Cmd: cmd=resource.manage, required_permission=p.WRITE, ), + "resource.update_meta": _Cmd( + cmd=resource.update_meta, + required_permission=p.WRITE, + ), "resource.move": _Cmd( cmd=resource.move, required_permission=p.WRITE, @@ -333,6 +383,10 @@ class _Cmd: cmd=resource.move_autoclean, required_permission=p.WRITE, ), + "resource.restart": _Cmd( + cmd=resource.restart, + required_permission=p.WRITE, + ), "resource.unmanage": _Cmd( cmd=resource.unmanage, required_permission=p.WRITE, @@ -384,6 +438,10 @@ class _Cmd: cmd=stonith.create_in_group, required_permission=p.WRITE, ), + "tag.get_config_dto": _Cmd( + cmd=tag.get_config_dto, + required_permission=p.READ, + ), # CMDs allowed in pcs_internal but not exposed via REST API: # "services.disable_service": Cmd(services.disable_service, # "services.enable_service": Cmd(services.enable_service, diff --git a/pcs/daemon/async_tasks/worker/executor.py b/pcs/daemon/async_tasks/worker/executor.py index db2648265..1b1555d06 100644 --- a/pcs/daemon/async_tasks/worker/executor.py +++ b/pcs/daemon/async_tasks/worker/executor.py @@ -69,7 +69,7 @@ def worker_init(message_q: mp.Queue, logging_q: mp.Queue) -> None: logger.info("Worker initialized.") # Let task_executor use worker_com for sending messages to the scheduler - global worker_com + global worker_com # noqa: PLW0603 worker_com = WorkerCommunicator(message_q) def ignore_signals(sig_num, frame): # type: ignore @@ -249,7 +249,7 @@ def _param_to_field_tuple( param.name, field_type, # pylint: disable=invalid-field-call - # this is actually used whitin make_dataclass function + # this is actually used within make_dataclass function dataclasses.field(default=param.default), ) return (param.name, field_type) diff --git a/pcs/daemon/async_tasks/worker/logging.py b/pcs/daemon/async_tasks/worker/logging.py index 1319d6512..954eddbf3 100644 --- a/pcs/daemon/async_tasks/worker/logging.py +++ b/pcs/daemon/async_tasks/worker/logging.py @@ -7,8 +7,7 @@ class Logger(logging.Logger): - # pylint: disable=too-many-arguments - def makeRecord( # type: ignore + def makeRecord( # type: ignore # noqa: PLR0913 self, name, level, @@ -21,6 +20,8 @@ def makeRecord( # type: ignore extra=None, sinfo=None, ) -> logging.LogRecord: + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments pid = os.getpid() return super().makeRecord( name, diff --git a/pcs/daemon/env.py b/pcs/daemon/env.py index 3d08edab5..ebc02b70f 100644 --- a/pcs/daemon/env.py +++ b/pcs/daemon/env.py @@ -1,13 +1,8 @@ +# ruff: noqa: B019 https://docs.astral.sh/ruff/rules/cached-instance-method/ +import os.path import ssl from collections import namedtuple from functools import lru_cache -from os.path import ( - abspath, - dirname, -) -from os.path import exists as path_exists -from os.path import join as join_path -from os.path import realpath from pcs import settings from pcs.common.validate import ( @@ -16,9 +11,11 @@ ) # Relative location instead of system location is used for development purposes. -PCSD_LOCAL_DIR = realpath(dirname(abspath(__file__)) + "/../../pcsd") - -PCSD_STATIC_FILES_DIR_NAME = "public" +LOCAL_PUBLIC_DIR = os.path.realpath( + os.path.dirname(os.path.abspath(__file__)) + "/../../pcsd/public" +) +LOCAL_WEBUI_DIR = os.path.join(LOCAL_PUBLIC_DIR, "ui") +WEBUI_FALLBACK_FILE = "ui_instructions.html" PCSD_PORT = "PCSD_PORT" PCSD_SSL_CIPHERS = "PCSD_SSL_CIPHERS" @@ -29,7 +26,8 @@ PCSD_DISABLE_GUI = "PCSD_DISABLE_GUI" PCSD_SESSION_LIFETIME = "PCSD_SESSION_LIFETIME" PCSD_DEV = "PCSD_DEV" -PCSD_STATIC_FILES_DIR = "PCSD_STATIC_FILES_DIR" +WEBUI_DIR = "WEBUI_DIR" +WEBUI_FALLBACK = "WEBUI_FALLBACK" PCSD_WORKER_COUNT = "PCSD_WORKER_COUNT" PCSD_WORKER_RESET_LIMIT = "PCSD_WORKER_RESET_LIMIT" PCSD_MAX_WORKER_COUNT = "PCSD_MAX_WORKER_COUNT" @@ -50,7 +48,8 @@ PCSD_DEBUG, PCSD_DISABLE_GUI, PCSD_SESSION_LIFETIME, - PCSD_STATIC_FILES_DIR, + WEBUI_DIR, + WEBUI_FALLBACK, PCSD_DEV, PCSD_WORKER_COUNT, PCSD_WORKER_RESET_LIMIT, @@ -67,6 +66,7 @@ def prepare_env(environ, logger=None): loader = EnvLoader(environ) + loader.check_webui() env = Env( loader.port(), loader.ssl_ciphers(), @@ -76,7 +76,8 @@ def prepare_env(environ, logger=None): loader.pcsd_debug(), loader.pcsd_disable_gui(), loader.session_lifetime(), - loader.pcsd_static_files_dir(), + loader.webui_dir(), + loader.webui_fallback(), loader.pcsd_dev(), loader.pcsd_worker_count(), loader.pcsd_worker_reset_limit(), @@ -120,6 +121,7 @@ def str_to_ssl_options(ssl_options_string, reports): class EnvLoader: + # pylint: disable=too-many-public-methods def __init__(self, environ): self.environ = environ self.errors = [] @@ -190,11 +192,25 @@ def session_lifetime(self): def pcsd_debug(self): return self.__has_true_in_environ(PCSD_DEBUG) - def pcsd_static_files_dir(self): - return self.__in_pcsd_path( - PCSD_STATIC_FILES_DIR_NAME, - "Directory with web UI assets", - existence_required=not self.pcsd_disable_gui(), + def check_webui(self): + if not self.pcsd_disable_gui() and not ( + os.path.exists(self.webui_dir()) + or os.path.exists(self.webui_fallback()) + ): + self.errors.append( + f"Webui assets directory '{self.webui_dir()}'" + + f" or fallback html '{self.webui_fallback()}' does not exist" + ) + + @lru_cache(maxsize=5) + def webui_dir(self): + return LOCAL_WEBUI_DIR if self.pcsd_dev() else settings.pcsd_webui_dir + + @lru_cache(maxsize=5) + def webui_fallback(self): + return os.path.join( + LOCAL_PUBLIC_DIR if self.pcsd_dev() else settings.pcsd_public_dir, + WEBUI_FALLBACK_FILE, ) @lru_cache(maxsize=5) @@ -270,15 +286,5 @@ def pcsd_task_deletion_timeout(self) -> int: PCSD_TASK_DELETION_TIMEOUT, settings.task_deletion_timeout_seconds ) - def __in_pcsd_path(self, path, description="", existence_required=True): - pcsd_dir = ( - PCSD_LOCAL_DIR if self.pcsd_dev() else settings.pcsd_exec_location - ) - - in_pcsd_path = join_path(pcsd_dir, path) - if existence_required and not path_exists(in_pcsd_path): - self.errors.append(f"{description} '{in_pcsd_path}' does not exist") - return in_pcsd_path - def __has_true_in_environ(self, environ_key): return self.environ.get(environ_key, "").lower() == "true" diff --git a/pcs/daemon/log.py b/pcs/daemon/log.py index a38cbbdf0..b746bb9e8 100644 --- a/pcs/daemon/log.py +++ b/pcs/daemon/log.py @@ -17,7 +17,7 @@ def from_external_source(level, created: float, usecs: int, message, group_id): record = pcsd.makeRecord( name=pcsd.name, level=level, - # Information about stack fram is not needed here. Values are + # Information about stack frame is not needed here. Values are # inspired by the code of the logging module. fn="(external)", lno=0, diff --git a/pcs/daemon/ruby_pcsd.py b/pcs/daemon/ruby_pcsd.py index 1956d2072..e492ae077 100644 --- a/pcs/daemon/ruby_pcsd.py +++ b/pcs/daemon/ruby_pcsd.py @@ -46,6 +46,8 @@ def get_request_id(): class SinatraResult(namedtuple("SinatraResult", "headers, status, body")): + __slots__ = () + @classmethod def from_response(cls, response): return cls(response["headers"], response["status"], response["body"]) @@ -81,6 +83,8 @@ class RubyDaemonRequest( "RubyDaemonRequest", "request_type, path, query, headers, method, body" ) ): + __slots__ = () + def __new__( cls, request_type, diff --git a/pcs/daemon/run.py b/pcs/daemon/run.py index cecce750f..48b50bf96 100644 --- a/pcs/daemon/run.py +++ b/pcs/daemon/run.py @@ -9,6 +9,16 @@ Optional, ) +try: + from tornado.httputil import ( + ParseBodyConfig, + ParseMultipartConfig, + set_parse_body_config, + ) + + _TORNADO_SUPPORTS_DISABLE_MULTIPART = True +except ImportError: + _TORNADO_SUPPORTS_DISABLE_MULTIPART = False from tornado.ioloop import ( IOLoop, PeriodicCallback, @@ -31,13 +41,11 @@ api_v1, api_v2, auth, -) -from pcs.daemon.app import capabilities as capabilities_app -from pcs.daemon.app import ( sinatra_remote, sinatra_ui, ui, ) +from pcs.daemon.app import capabilities as capabilities_app from pcs.daemon.app.common import ( Http404Handler, RedirectHandler, @@ -83,14 +91,16 @@ async def config_synchronization(): return config_synchronization -def configure_app( +def configure_app( # noqa: PLR0913 async_scheduler: Scheduler, auth_provider: AuthProvider, session_storage: session.Storage, ruby_pcsd_wrapper: ruby_pcsd.Wrapper, sync_config_lock: Lock, - public_dir: str, + webui_dir: str, + webui_fallback: str, pcsd_capabilities: Iterable[capabilities.Capability], + *, disable_gui: bool = False, debug: bool = False, ): @@ -101,6 +111,13 @@ def make_app(https_server_manage: HttpsServerManage): reload its SSL certificates). A relevant handler should get this object via the method `initialize`. """ + if _TORNADO_SUPPORTS_DISABLE_MULTIPART: + # Disable multipart requests to enhance security due to recent CVEs + # but only if Tornado supports it (added in Tornado 6.5.5) + # https://www.tornadoweb.org/en/stable/httputil.html#tornado.httputil.set_parse_body_config + set_parse_body_config( + ParseBodyConfig(multipart=ParseMultipartConfig(enabled=False)) + ) routes = api_v2.get_routes(async_scheduler, auth_provider) routes.extend(api_v1.get_routes(async_scheduler, auth_provider)) @@ -123,11 +140,8 @@ def make_app(https_server_manage: HttpsServerManage): [(r"/(ui)?", RedirectHandler, dict(url="/ui/"))] + ui.get_routes( url_prefix="/ui/", - app_dir=os.path.join(public_dir, "ui"), - fallback_page_path=os.path.join( - public_dir, - "ui_instructions.html", - ), + app_dir=webui_dir, + fallback_page_path=webui_fallback, session_storage=session_storage, auth_provider=auth_provider, ) @@ -220,7 +234,8 @@ def main(argv=None) -> None: session.Storage(env.PCSD_SESSION_LIFETIME), ruby_pcsd_wrapper, sync_config_lock, - env.PCSD_STATIC_FILES_DIR, + env.WEBUI_DIR, + env.WEBUI_FALLBACK, pcsd_capabilities, disable_gui=env.PCSD_DISABLE_GUI, debug=env.PCSD_DEV, diff --git a/pcs/daemon/session.py b/pcs/daemon/session.py index 6f5879312..0d36c77e8 100644 --- a/pcs/daemon/session.py +++ b/pcs/daemon/session.py @@ -10,7 +10,7 @@ def __init__( sid: str, username: str, ) -> None: - # Session id propageted via cookies. + # Session id propagated via cookies. self.__sid = sid # Username given by login attempt. It does not matter if the # authentication succeeded. diff --git a/pcs/entry_points/cli.py b/pcs/entry_points/cli.py index 0d1dcad94..757130b5a 100644 --- a/pcs/entry_points/cli.py +++ b/pcs/entry_points/cli.py @@ -5,4 +5,4 @@ add_bundled_packages_to_path() -from pcs.app import main +from pcs.app import main # noqa: E402 diff --git a/pcs/entry_points/daemon.py b/pcs/entry_points/daemon.py index 6287b4471..1341bf450 100644 --- a/pcs/entry_points/daemon.py +++ b/pcs/entry_points/daemon.py @@ -5,4 +5,4 @@ add_bundled_packages_to_path() -from pcs.daemon.run import main +from pcs.daemon.run import main # noqa: E402 diff --git a/pcs/entry_points/internal.py b/pcs/entry_points/internal.py index 4f828d9c1..29ae7e60f 100644 --- a/pcs/entry_points/internal.py +++ b/pcs/entry_points/internal.py @@ -5,4 +5,4 @@ add_bundled_packages_to_path() -from pcs.pcs_internal import main +from pcs.pcs_internal import main # noqa: E402 diff --git a/pcs/entry_points/snmp_agent.py b/pcs/entry_points/snmp_agent.py index f3e460059..520baa82e 100644 --- a/pcs/entry_points/snmp_agent.py +++ b/pcs/entry_points/snmp_agent.py @@ -5,4 +5,4 @@ add_bundled_packages_to_path() -from pcs.snmp.pcs_snmp_agent import main +from pcs.snmp.pcs_snmp_agent import main # noqa: E402 diff --git a/pcs/host.py b/pcs/host.py index 09621dd4a..a50df6502 100644 --- a/pcs/host.py +++ b/pcs/host.py @@ -23,7 +23,7 @@ def _parse_host_options( host: str, options: Argv ) -> dict[str, Union[str, list[dict[str, Union[None, str, int]]]]]: ADDR_OPT_KEYWORD = "addr" # pylint: disable=invalid-name - supported_options = set([ADDR_OPT_KEYWORD]) + supported_options = {ADDR_OPT_KEYWORD} parsed_options = KeyValueParser(options).get_unique() unknown_options = set(parsed_options.keys()) - supported_options if unknown_options: diff --git a/pcs/lib/auth/tools.py b/pcs/lib/auth/tools.py index b40b12d11..0cdade26e 100644 --- a/pcs/lib/auth/tools.py +++ b/pcs/lib/auth/tools.py @@ -1,13 +1,15 @@ import grp import pwd - -from pcs.common.tools import StringCollection +from typing import TYPE_CHECKING from .types import ( AuthUser, DesiredUser, ) +if TYPE_CHECKING: + from pcs.common.tools import StringCollection + class UserGroupsError(Exception): pass diff --git a/pcs/lib/booth/cib.py b/pcs/lib/booth/cib.py new file mode 100644 index 000000000..3e3903bf9 --- /dev/null +++ b/pcs/lib/booth/cib.py @@ -0,0 +1,12 @@ +from typing import cast + +from lxml.etree import _Element + + +def get_ticket_names(cib: _Element) -> list[str]: + """ + Return names of all tickets present in CIB + + cib -- element representing the CIB + """ + return cast(list[str], cib.xpath("status/tickets/ticket_state/@id")) diff --git a/pcs/lib/booth/config_facade.py b/pcs/lib/booth/config_facade.py index af9931cf8..706d1fbdc 100644 --- a/pcs/lib/booth/config_facade.py +++ b/pcs/lib/booth/config_facade.py @@ -54,7 +54,7 @@ def has_ticket(self, ticket_name): string ticket_name -- the name of the ticket """ - for key, value, dummy_details in self._config: + for key, value, _ in self._config: if key == "ticket" and value == ticket_name: return True return False @@ -97,7 +97,7 @@ def set_option(self, key: str, value: str) -> None: self._config.insert(0, ConfigItem(key, value)) def get_option(self, option: str) -> Optional[str]: - for key, value, dummy_details in reversed(self._config): + for key, value, _ in reversed(self._config): if key == option: return value return None diff --git a/pcs/lib/booth/config_parser.py b/pcs/lib/booth/config_parser.py index b122b7c7f..ea6a587c3 100644 --- a/pcs/lib/booth/config_parser.py +++ b/pcs/lib/booth/config_parser.py @@ -15,6 +15,8 @@ class ConfigItem(namedtuple("ConfigItem", "key value details")): + __slots__ = () + def __new__(cls, key, value, details=None): return super().__new__(cls, key, value, details or []) @@ -111,8 +113,8 @@ def _parse_to_raw_lines(config_content): line_list = [] invalid_line_list = [] - for line in config_content.splitlines(): - line = line.strip() + for config_line in config_content.splitlines(): + line = config_line.strip() match = _search_with_multiple_re(expression_list, line) if match: line_list.append((match.group("key"), match.group("value"))) diff --git a/pcs/lib/booth/env.py b/pcs/lib/booth/env.py index 5cb5b479b..a9a429986 100644 --- a/pcs/lib/booth/env.py +++ b/pcs/lib/booth/env.py @@ -68,7 +68,7 @@ def __init__(self, instance_name: Optional[str], booth_files_data): @staticmethod def _init_file_data(booth_files_data, file_key): # ghost file not specified - if not file_key in booth_files_data: + if file_key not in booth_files_data: return dict( ghost_file=False, ghost_data=None, diff --git a/pcs/lib/booth/resource.py b/pcs/lib/booth/resource.py index 83db5b24e..6daff50d9 100644 --- a/pcs/lib/booth/resource.py +++ b/pcs/lib/booth/resource.py @@ -1,5 +1,5 @@ from typing import ( - List, + Iterable, cast, ) @@ -36,22 +36,24 @@ def find_grouped_ip_element_to_remove(booth_element): return None -def get_remover(resource_remove): - def remove_from_cluster(booth_element_list): - for element in booth_element_list: - ip_resource_to_remove = find_grouped_ip_element_to_remove(element) - if ip_resource_to_remove is not None: - resource_remove(ip_resource_to_remove.attrib["id"]) - resource_remove(element.attrib["id"]) +def find_elements_to_remove( + booth_element_list: Iterable[_Element], +) -> list[_Element]: + elements_to_remove = [] + for element in booth_element_list: + ip_resource_to_remove = find_grouped_ip_element_to_remove(element) + if ip_resource_to_remove is not None: + elements_to_remove.append(ip_resource_to_remove) + elements_to_remove.append(element) - return remove_from_cluster + return elements_to_remove def find_for_config( resources_section: _Element, booth_config_file_path: str -) -> List[_Element]: +) -> list[_Element]: return cast( - List[_Element], + list[_Element], resources_section.xpath( """ .//primitive[ @@ -69,9 +71,9 @@ def find_for_config( def find_bound_ip( resources_section: _Element, booth_config_file_path: str -) -> List[_Element]: +) -> list[str]: return cast( - List[_Element], + list[str], resources_section.xpath( """ .//group[ diff --git a/pcs/lib/booth/status.py b/pcs/lib/booth/status.py index b495c6dc2..d9aeead04 100644 --- a/pcs/lib/booth/status.py +++ b/pcs/lib/booth/status.py @@ -1,16 +1,21 @@ -from typing import Optional +from typing import ( + TYPE_CHECKING, + Optional, +) from pcs import settings from pcs.common import reports from pcs.common.file import RawFileError from pcs.common.str_tools import join_multilines -from pcs.lib.booth.config_facade import ConfigFacade from pcs.lib.booth.constants import AUTHFILE_FIX_OPTION from pcs.lib.booth.env import BoothEnv from pcs.lib.errors import LibraryError from pcs.lib.file.raw_file import raw_file_error_report from pcs.lib.interface.config import ParserErrorException +if TYPE_CHECKING: + from pcs.lib.booth.config_facade import ConfigFacade + def get_daemon_status(runner, name=None): cmd = [settings.booth_exec, "status"] diff --git a/pcs/lib/cib/acl.py b/pcs/lib/cib/acl.py index 83e2ed8b1..239f49239 100644 --- a/pcs/lib/cib/acl.py +++ b/pcs/lib/cib/acl.py @@ -1,13 +1,16 @@ from functools import partial +from typing import Optional from lxml import etree +from lxml.etree import _Element from pcs.common import reports +from pcs.lib import validate from pcs.lib.cib.tools import ( + IdProvider, check_new_id_applicable, does_id_exist, find_element_by_tag_and_id, - find_unique_id, ) from pcs.lib.errors import LibraryError @@ -16,11 +19,14 @@ TAG_TARGET = "acl_target" TAG_PERMISSION = "acl_permission" +PermissionInfoList = list[tuple[str, str, str]] -def validate_permissions(tree, permission_info_list): + +def validate_permissions( + tree: _Element, permission_info_list: PermissionInfoList +) -> reports.ReportItemList: """ - Validate given permission list. - Raise LibraryError if any of permission is not valid. + Validate given permission list tree -- cib tree permission_info_list -- list of tuples like this: @@ -30,7 +36,7 @@ def validate_permissions(tree, permission_info_list): allowed_permissions = ["read", "write", "deny"] allowed_scopes = ["xpath", "id"] for permission, scope_type, scope in permission_info_list: - if not permission in allowed_permissions: + if permission not in allowed_permissions: report_items.append( reports.ReportItem.error( reports.messages.InvalidOptionValue( @@ -39,7 +45,7 @@ def validate_permissions(tree, permission_info_list): ) ) - if not scope_type in allowed_scopes: + if scope_type not in allowed_scopes: report_items.append( reports.ReportItem.error( reports.messages.InvalidOptionValue( @@ -55,8 +61,7 @@ def validate_permissions(tree, permission_info_list): ) ) - if report_items: - raise LibraryError(*report_items) + return report_items def _find(tag, acl_section, element_id, none_if_id_unused=False, id_types=None): @@ -104,15 +109,33 @@ def find_target_or_group(acl_section, target_or_group_id): ) -def create_role(acl_section, role_id, description=None): +def validate_create_role( + id_provider: IdProvider, role_id: str, description: Optional[str] = None +) -> reports.ReportItemList: + """ + Validate creating a new role + + id_provider -- id provider + role_id -- id of desired role + description -- role description + """ + del description + validators = [ + validate.ValueId("role id", "ACL role", id_provider), + ] + return validate.ValidatorAll(validators).validate({"role id": role_id}) + + +def create_role( + acl_section: _Element, role_id: str, description: Optional[str] = None +) -> _Element: """ - Create new role element and add it to cib. - Returns newly created role element. + Create new role element, add it to cib and return it - role_id id of desired role - description role description + acl_section -- parent element for the new role + role_id -- id of desired role + description -- role description """ - check_new_id_applicable(acl_section, "ACL role", role_id) role = etree.SubElement(acl_section, TAG_ROLE, id=role_id) if description: role.set("description", description) @@ -212,15 +235,6 @@ def unassign_role(target_el, role_id, autodelete_target=False): target_el.getparent().remove(target_el) -def provide_role(acl_section, role_id): - """ - Returns role with id role_id. If doesn't exist, it will be created. - role_id id of desired role - """ - role = find_role(acl_section, role_id, none_if_id_unused=True) - return role if role is not None else create_role(acl_section, role_id) - - def create_target(acl_section, target_id): """ Creates new acl_target element with id target_id. @@ -278,13 +292,18 @@ def remove_group(acl_section, group_id): group.getparent().remove(group) -def add_permissions_to_role(role_el, permission_info_list): +def add_permissions_to_role( + role_el: _Element, + permission_info_list: PermissionInfoList, + id_provider: IdProvider, +) -> None: """ Add permissions from permission_info_list to role_el. role_el -- acl_role element to which permissions should be added permission_info_list -- list of tuples, each contains (permission, scope_type, scope) + id_provider -- id provider """ area_type_attribute_map = { "xpath": "xpath", @@ -294,8 +313,8 @@ def add_permissions_to_role(role_el, permission_info_list): perm = etree.SubElement(role_el, "acl_permission") perm.set( "id", - find_unique_id( - role_el, "{0}-{1}".format(role_el.get("id", "role"), permission) + id_provider.allocate_id( + "{0}-{1}".format(role_el.get("id", "role"), permission) ), ) perm.set("kind", permission) @@ -350,23 +369,21 @@ def _get_permission_list(role_el): role_el -- acl_role etree element of which permissions would be returned """ - output_list = [] - for permission in role_el.findall("./acl_permission"): - output_list.append( - { - key: permission.get(key) - for key in [ - "id", - "description", - "kind", - "xpath", - "reference", - "object-type", - "attribute", - ] - } - ) - return output_list + return [ + { + key: permission.get(key) + for key in [ + "id", + "description", + "kind", + "xpath", + "reference", + "object-type", + "attribute", + ] + } + for permission in role_el.findall("./acl_permission") + ] def get_target_list(acl_section): @@ -396,17 +413,15 @@ def get_group_list(acl_section): def get_target_like_list(acl_section, tag): - output_list = [] - for target_el in acl_section.xpath( - "./*[local-name()=$tag_name]", tag_name=tag - ): - output_list.append( - { - "id": target_el.get("id"), - "role_list": _get_role_list_of_target(target_el), - } + return [ + { + "id": target_el.get("id"), + "role_list": _get_role_list_of_target(target_el), + } + for target_el in acl_section.xpath( + "./*[local-name()=$tag_name]", tag_name=tag ) - return output_list + ] def _get_role_list_of_target(target): @@ -432,10 +447,3 @@ def remove_permissions_referencing(tree, reference): ".//acl_permission[@reference=$reference]", reference=reference ): permission.getparent().remove(permission) - - -def dom_remove_permissions_referencing(dom, reference): - # TODO: remove once we go fully lxml - for permission in dom.getElementsByTagName("acl_permission"): - if permission.getAttribute("reference") == reference: - permission.parentNode.removeChild(permission) diff --git a/pcs/lib/cib/alert.py b/pcs/lib/cib/alert.py index 40607b5e1..d33fd0ee9 100644 --- a/pcs/lib/cib/alert.py +++ b/pcs/lib/cib/alert.py @@ -1,107 +1,168 @@ -from functools import partial +from typing import Any, Optional, cast from lxml import etree +from lxml.etree import _Element from pcs.common import reports -from pcs.common.reports import ReportProcessor -from pcs.common.reports.item import ReportItem +from pcs.common.pacemaker.alert import ( + CibAlertDto, + CibAlertRecipientDto, + CibAlertSelectAttributeDto, + CibAlertSelectDto, +) +from pcs.lib.cib import nvpair_multi, rule from pcs.lib.cib.nvpair import get_nvset from pcs.lib.cib.tools import ( - check_new_id_applicable, - find_element_by_tag_and_id, - find_unique_id, + ElementSearcher, + IdProvider, + create_subelement_id, get_alerts, - validate_id_does_not_exist, ) from pcs.lib.errors import LibraryError -from pcs.lib.xml_tools import get_sub_element +from pcs.lib.pacemaker.values import validate_id_reports +from pcs.lib.xml_tools import ( + get_sub_element, + remove_one_element, + update_attribute_remove_empty, +) TAG_ALERT = "alert" TAG_RECIPIENT = "recipient" -find_alert = partial(find_element_by_tag_and_id, TAG_ALERT) -find_recipient = partial(find_element_by_tag_and_id, TAG_RECIPIENT) +def get_all_alert_elements(tree: _Element) -> list[_Element]: + return tree.findall(TAG_ALERT) + + +def find_alert(context_el: _Element, alert_id: str) -> _Element: + searcher = ElementSearcher(TAG_ALERT, alert_id, context_el) + found_element = searcher.get_element() + if found_element is not None: + return found_element + raise LibraryError(*searcher.get_errors()) + + +def find_recipient(context_el: _Element, recipient_id: str) -> _Element: + searcher = ElementSearcher(TAG_RECIPIENT, recipient_id, context_el) + found_element = searcher.get_element() + if found_element is not None: + return found_element + raise LibraryError(*searcher.get_errors()) -def _update_optional_attribute(element, attribute, value): + +def _update_optional_attribute( + element: _Element, attribute: str, value: Optional[str] +) -> None: """ - Update optional attribute of element. Remove existing element if value - is empty. + Set value of an optional attribute, remove the attribute on empty value - element -- parent element of specified attribute + element -- element to be updated attribute -- attribute to be updated - value -- new value + value -- new value of the attribute """ if value is None: return - if value: - element.set(attribute, value) - elif attribute in element.attrib: - del element.attrib[attribute] - - -def ensure_recipient_value_is_unique( - reporter: ReportProcessor, - alert, - recipient_value, - recipient_id="", - allow_duplicity=False, -): + update_attribute_remove_empty(element, attribute, value) + + +def _validate_recipient_value_is_unique( + alert_el: _Element, + recipient_value: str, + recipient_id: Optional[str] = None, + allow_duplicity: bool = False, +) -> reports.ReportItemList: """ - Ensures that recipient_value is unique in alert. + Validate that the recipient_value is unique in the specified alert - reporter -- report processor - alert -- alert + alert_el -- alert recipient_value -- recipient value - recipient_id -- recipient id of to which value belongs to - allow_duplicity -- if True only warning will be shown if value already - exists + recipient_id -- id of the recipient to which the value belongs + allow_duplicity -- if True, report a warning if the value already exists """ - recipient_list = alert.xpath( + report_list: reports.ReportItemList = [] + recipient_list = alert_el.xpath( "./recipient[@value=$value and @id!=$id]", value=recipient_value, - id=recipient_id, + id=recipient_id or "", ) if recipient_list: - reporter.report( - ReportItem( + report_list.append( + reports.ReportItem( severity=reports.item.get_severity( reports.codes.FORCE, allow_duplicity, ), message=reports.messages.CibAlertRecipientAlreadyExists( - alert.get("id", None), + str(alert_el.attrib["id"]), recipient_value, ), ) ) - if reporter.has_errors: - raise LibraryError() + return report_list -def create_alert(tree, alert_id, path, description=""): +def validate_create_alert( + id_provider: IdProvider, + # should be str, see lib.commands.alert.create_alert + path: Optional[str], + alert_id: Optional[str] = None, +) -> reports.ReportItemList: """ - Create new alert element. Returns newly created element. - Raises LibraryError if element with specified id already exists. + validate new alert creation + + id_provider -- elements' ids generator + path -- path to script + alert_id -- id of new alert or None + """ + report_list: reports.ReportItemList = [] + if not path: + report_list.append( + reports.ReportItem.error( + reports.messages.RequiredOptionsAreMissing(["path"]) + ) + ) + if alert_id: + report_list.extend( + validate_id_reports(alert_id, description="alert-id") + ) + report_list.extend(id_provider.book_ids(alert_id)) + return report_list + + +def create_alert( + tree: _Element, + id_provider: IdProvider, + path: str, + alert_id: Optional[str] = None, + description: Optional[str] = None, +) -> _Element: + """ + Create new alert element and return it tree -- cib etree node - alert_id -- id of new alert, it will be generated if it is None + id_provider -- elements' ids generator path -- path to script + alert_id -- id of new alert, it will be generated if it is None description -- description """ - if alert_id: - check_new_id_applicable(tree, "alert-id", alert_id) - else: - alert_id = find_unique_id(tree, "alert") + if not alert_id: + alert_id = id_provider.allocate_id(TAG_ALERT) - alert = etree.SubElement(get_alerts(tree), "alert", id=alert_id, path=path) + alert_el = etree.SubElement( + get_alerts(tree), TAG_ALERT, id=alert_id, path=path + ) if description: - alert.set("description", description) + alert_el.set("description", description) - return alert + return alert_el -def update_alert(tree, alert_id, path, description=None): +def update_alert( + tree: _Element, + alert_id: str, + path: Optional[str], + description: Optional[str] = None, +) -> _Element: """ Update existing alert. Return updated alert element. Raises LibraryError if alert with specified id doesn't exist. @@ -119,7 +180,7 @@ def update_alert(tree, alert_id, path, description=None): return alert -def remove_alert(tree, alert_id): +def remove_alert(tree: _Element, alert_id: str) -> None: """ Remove alert with specified id. Raises LibraryError if alert with specified id doesn't exist. @@ -127,86 +188,137 @@ def remove_alert(tree, alert_id): tree -- cib etree node alert_id -- id of alert which should be removed """ - alert = find_alert(get_alerts(tree), alert_id) - alert.getparent().remove(alert) + remove_one_element(find_alert(get_alerts(tree), alert_id)) -def add_recipient( - reporter: ReportProcessor, - tree, - alert_id, - recipient_value, - recipient_id=None, - description="", - allow_same_value=False, -): +def validate_add_recipient( + id_provider: IdProvider, + alert_el: _Element, + # should be str, see lib.commands.alert.add_recipient + recipient_value: Optional[str], + recipient_id: Optional[str] = None, + allow_same_value: bool = False, +) -> reports.ReportItemList: """ - Add recipient to alert with specified id. Returns added recipient element. - Raises LibraryError if alert with specified recipient_id doesn't exist. - Raises LibraryError if recipient already exists. + Validate adding a recipient to the specified alert - reporter -- report processor - tree -- cib etree node - alert_id -- id of alert which should be parent of new recipient - recipient_value -- value of recipient - recipient_id -- id of new recipient, if None it will be generated - description -- description of recipient - allow_same_value -- if True unique recipient value is not required + id_provider -- elements' ids generator + alert_el -- alert which should be the parent of the new recipient + recipient_value -- value of the new recipient + recipient_id -- id of the new recipient or None + allow_same_value -- if True, unique recipient value is not required """ - if recipient_id is None: - recipient_id = find_unique_id(tree, f"{alert_id}-recipient") + report_list: reports.ReportItemList = [] + + if not recipient_value: + report_list.append( + reports.ReportItem.error( + reports.messages.RequiredOptionsAreMissing(["value"]) + ) + ) else: - validate_id_does_not_exist(tree, recipient_id) + report_list.extend( + _validate_recipient_value_is_unique( + alert_el, + recipient_value, + recipient_id, + allow_duplicity=allow_same_value, + ) + ) - alert = find_alert(get_alerts(tree), alert_id) - ensure_recipient_value_is_unique( - reporter, alert, recipient_value, allow_duplicity=allow_same_value - ) - recipient = etree.SubElement( - alert, "recipient", id=recipient_id, value=recipient_value - ) + if recipient_id: + report_list.extend( + validate_id_reports(recipient_id, description="recipient-id") + ) + report_list.extend(id_provider.book_ids(recipient_id)) + + return report_list + + +def add_recipient( + id_provider: IdProvider, + alert_el: _Element, + recipient_value: str, + recipient_id: Optional[str] = None, + description: Optional[str] = None, +) -> _Element: + """ + Add new recipient to the specified alert, return added recipient element + id_provider -- elements' ids generator + alert_el -- alert which should be the parent of the new recipient + recipient_value -- value of the new recipient + recipient_id -- id of the new recipient or None + description -- description of the new recipient + """ + if not recipient_id: + recipient_id = create_subelement_id( + alert_el, TAG_RECIPIENT, id_provider + ) + recipient_el = etree.SubElement( + alert_el, TAG_RECIPIENT, id=recipient_id, value=recipient_value + ) if description: - recipient.attrib["description"] = description + recipient_el.attrib["description"] = description + return recipient_el - return recipient + +def validate_update_recipient( + recipient_el: _Element, + recipient_value: Optional[str] = None, + allow_same_value: bool = False, +) -> reports.ReportItemList: + """ + validate updating specified recipient + + recipient_el -- the recipient to be updated + recipient_value -- new recipient value, stay unchanged if None + allow_same_value -- if True, unique recipient value is not required + """ + report_list: reports.ReportItemList = [] + + if recipient_value is not None: + if not recipient_value: + report_list.append( + reports.ReportItem.error( + reports.messages.CibAlertRecipientValueInvalid( + recipient_value + ) + ) + ) + else: + report_list.extend( + _validate_recipient_value_is_unique( + cast(_Element, recipient_el.getparent()), + recipient_value, + str(recipient_el.attrib["id"]), + allow_duplicity=allow_same_value, + ) + ) + + return report_list def update_recipient( - reporter: ReportProcessor, - tree, - recipient_id, - recipient_value=None, - description=None, - allow_same_value=False, -): + recipient_el: _Element, + recipient_value: Optional[str] = None, + description: Optional[str] = None, +) -> _Element: """ Update specified recipient. Returns updated recipient element. - Raises LibraryError if recipient doesn't exist. - reporter -- report processor - tree -- cib etree node - recipient_id -- id of recipient to be updated - recipient_value -- recipient value, stay unchanged if None + recipient_el -- the recipient to be updated + recipient_value -- new recipient value, stay unchanged if None description -- description, if empty it will be removed, stay unchanged if None - allow_same_value -- if True unique recipient value is not required """ - recipient = find_recipient(get_alerts(tree), recipient_id) if recipient_value is not None: - ensure_recipient_value_is_unique( - reporter, - recipient.getparent(), - recipient_value, - recipient_id=recipient_id, - allow_duplicity=allow_same_value, - ) - recipient.set("value", recipient_value) - _update_optional_attribute(recipient, "description", description) - return recipient + recipient_el.set("value", recipient_value) + _update_optional_attribute(recipient_el, "description", description) + return recipient_el -def remove_recipient(tree, recipient_id): +def remove_recipient(tree: _Element, recipient_id: str) -> None: """ Remove specified recipient. Raises LibraryError if recipient doesn't exist. @@ -214,11 +326,82 @@ def remove_recipient(tree, recipient_id): tree -- cib etree node recipient_id -- id of recipient to be removed """ - recipient = find_recipient(get_alerts(tree), recipient_id) - recipient.getparent().remove(recipient) + remove_one_element(find_recipient(get_alerts(tree), recipient_id)) + + +def _recipient_el_to_dto( + recipient_el: _Element, + rule_eval: Optional[rule.RuleInEffectEval] = None, +) -> CibAlertRecipientDto: + if rule_eval is None: + rule_eval = rule.RuleInEffectEvalDummy() + return CibAlertRecipientDto( + id=str(recipient_el.attrib["id"]), + value=str(recipient_el.attrib["value"]), + description=recipient_el.get("description"), + meta_attributes=[ + nvpair_multi.nvset_element_to_dto(nvset, rule_eval) + for nvset in nvpair_multi.find_nvsets( + recipient_el, nvpair_multi.NVSET_META + ) + ], + instance_attributes=[ + nvpair_multi.nvset_element_to_dto(nvset, rule_eval) + for nvset in nvpair_multi.find_nvsets( + recipient_el, nvpair_multi.NVSET_INSTANCE + ) + ], + ) -def get_all_recipients(alert): +def _select_el_to_dto(select_el: _Element) -> CibAlertSelectDto: + return CibAlertSelectDto( + nodes=(select_el.find("select_nodes") is not None), + fencing=(select_el.find("select_fencing") is not None), + resources=(select_el.find("select_resources") is not None), + attributes=(select_el.find("select_attributes") is not None), + attributes_select=[ + CibAlertSelectAttributeDto( + str(attr_el.attrib["id"]), str(attr_el.attrib["name"]) + ) + for attr_el in select_el.iterfind("select_attributes/attribute") + ], + ) + + +def alert_el_to_dto( + alert_el: _Element, + rule_eval: Optional[rule.RuleInEffectEval] = None, +) -> CibAlertDto: + if rule_eval is None: + rule_eval = rule.RuleInEffectEvalDummy() + select_el = alert_el.find("select") + return CibAlertDto( + id=str(alert_el.attrib["id"]), + path=str(alert_el.attrib["path"]), + description=alert_el.get("description"), + recipients=[ + _recipient_el_to_dto(recipient_el, rule_eval) + for recipient_el in alert_el.iterfind(TAG_RECIPIENT) + ], + select=_select_el_to_dto(select_el) if select_el is not None else None, + meta_attributes=[ + nvpair_multi.nvset_element_to_dto(nvset, rule_eval) + for nvset in nvpair_multi.find_nvsets( + alert_el, nvpair_multi.NVSET_META + ) + ], + instance_attributes=[ + nvpair_multi.nvset_element_to_dto(nvset, rule_eval) + for nvset in nvpair_multi.find_nvsets( + alert_el, nvpair_multi.NVSET_INSTANCE + ) + ], + ) + + +# DEPRECATED, used only in get_all_alerts_dict +def get_all_recipients_dict(alert: _Element) -> list[dict[str, Any]]: """ Returns list of all recipient of specified alert. Format: [ @@ -233,25 +416,24 @@ def get_all_recipients(alert): alert -- parent element of recipients to return """ - recipient_list = [] - for recipient in alert.findall("./recipient"): - recipient_list.append( - { - "id": recipient.get("id"), - "value": recipient.get("value"), - "description": recipient.get("description", ""), - "instance_attributes": get_nvset( - get_sub_element(recipient, "instance_attributes") - ), - "meta_attributes": get_nvset( - get_sub_element(recipient, "meta_attributes") - ), - } - ) - return recipient_list + return [ + { + "id": recipient.get("id"), + "value": recipient.get("value"), + "description": recipient.get("description", ""), + "instance_attributes": get_nvset( + get_sub_element(recipient, "instance_attributes") + ), + "meta_attributes": get_nvset( + get_sub_element(recipient, "meta_attributes") + ), + } + for recipient in alert.findall("./recipient") + ] -def get_all_alerts(tree): +# DEPRECATED, use alert_el_to_dto + get_all_alert_elements +def get_all_alerts_dict(tree: _Element) -> list[dict[str, Any]]: """ Returns list of all alerts specified in tree. Format: [ @@ -267,20 +449,18 @@ def get_all_alerts(tree): tree -- cib etree node """ - alert_list = [] - for alert in get_alerts(tree).findall("./alert"): - alert_list.append( - { - "id": alert.get("id"), - "path": alert.get("path"), - "description": alert.get("description", ""), - "instance_attributes": get_nvset( - get_sub_element(alert, "instance_attributes") - ), - "meta_attributes": get_nvset( - get_sub_element(alert, "meta_attributes") - ), - "recipient_list": get_all_recipients(alert), - } - ) - return alert_list + return [ + { + "id": alert.get("id"), + "path": alert.get("path"), + "description": alert.get("description", ""), + "instance_attributes": get_nvset( + get_sub_element(alert, "instance_attributes") + ), + "meta_attributes": get_nvset( + get_sub_element(alert, "meta_attributes") + ), + "recipient_list": get_all_recipients_dict(alert), + } + for alert in get_alerts(tree).findall("./alert") + ] diff --git a/pcs/lib/cib/const.py b/pcs/lib/cib/const.py index 1149a931a..1ea5678e8 100644 --- a/pcs/lib/cib/const.py +++ b/pcs/lib/cib/const.py @@ -1,16 +1,24 @@ from typing import Final +TAG_ACL_GROUP: Final = "acl_group" +TAG_ACL_PERMISSION: Final = "acl_permission" +TAG_ACL_ROLE: Final = "acl_role" +TAG_ACL_TARGET: Final = "acl_target" TAG_CONSTRAINT_COLOCATION: Final = "rsc_colocation" TAG_CONSTRAINT_LOCATION: Final = "rsc_location" TAG_CONSTRAINT_ORDER: Final = "rsc_order" TAG_CONSTRAINT_TICKET: Final = "rsc_ticket" TAG_CRM_CONFIG: Final = "crm_config" +TAG_FENCING_LEVEL: Final = "fencing-level" TAG_OBJREF: Final = "obj_ref" TAG_RESOURCE_BUNDLE: Final = "bundle" TAG_RESOURCE_CLONE: Final = "clone" TAG_RESOURCE_GROUP: Final = "group" TAG_RESOURCE_MASTER: Final = "master" TAG_RESOURCE_PRIMITIVE: Final = "primitive" +TAG_RESOURCE_REF: Final = "resource_ref" +TAG_RESOURCE_SET: Final = "resource_set" +TAG_ROLE: Final = "role" TAG_RULE: Final = "rule" TAG_TAG: Final = "tag" diff --git a/pcs/lib/cib/constraint/colocation.py b/pcs/lib/cib/constraint/colocation.py index cb4d9de2b..ac7b9ba96 100644 --- a/pcs/lib/cib/constraint/colocation.py +++ b/pcs/lib/cib/constraint/colocation.py @@ -11,9 +11,9 @@ from pcs.common.reports.item import ReportItem from pcs.lib.cib.const import TAG_CONSTRAINT_COLOCATION as TAG from pcs.lib.cib.constraint import constraint +from pcs.lib.cib.constraint.common import is_set_constraint from pcs.lib.cib.constraint.resource_set import ( constraint_element_to_resource_set_dto_list, - is_set_constraint, ) from pcs.lib.cib.rule import RuleInEffectEval from pcs.lib.cib.rule.cib_to_dto import rule_element_to_dto diff --git a/pcs/lib/cib/constraint/common.py b/pcs/lib/cib/constraint/common.py index 3f95da236..126a3fe56 100644 --- a/pcs/lib/cib/constraint/common.py +++ b/pcs/lib/cib/constraint/common.py @@ -7,6 +7,7 @@ TAG_LIST_CONSTRAINABLE, TAG_LIST_CONSTRAINT, TAG_LIST_RESOURCE_MULTIINSTANCE, + TAG_RESOURCE_SET, ) from pcs.lib.xml_tools import find_parent @@ -15,6 +16,13 @@ def is_constraint(element: _Element) -> bool: return element.tag in TAG_LIST_CONSTRAINT +def is_set_constraint(element: _Element) -> bool: + return ( + is_constraint(element) + and element.find(f"./{TAG_RESOURCE_SET}") is not None + ) + + def validate_constrainable_elements( element_list: Iterable[_Element], in_multiinstance_allowed: bool = False ) -> reports.ReportItemList: diff --git a/pcs/lib/cib/constraint/constraint.py b/pcs/lib/cib/constraint/constraint.py index eca748ef9..8e1788793 100644 --- a/pcs/lib/cib/constraint/constraint.py +++ b/pcs/lib/cib/constraint/constraint.py @@ -22,9 +22,7 @@ def _validate_attrib_names(attrib_names, options): - invalid_names = [ - name for name in options.keys() if name not in attrib_names - ] + invalid_names = [name for name in options if name not in attrib_names] if invalid_names: raise LibraryError( reports.ReportItem.error( diff --git a/pcs/lib/cib/constraint/location.py b/pcs/lib/cib/constraint/location.py index 4952798df..45d548a6b 100644 --- a/pcs/lib/cib/constraint/location.py +++ b/pcs/lib/cib/constraint/location.py @@ -8,9 +8,9 @@ from pcs.common.pacemaker.types import CibResourceDiscovery from pcs.lib.cib.const import TAG_CONSTRAINT_LOCATION as TAG from pcs.lib.cib.const import TAG_RULE +from pcs.lib.cib.constraint.common import is_set_constraint from pcs.lib.cib.constraint.resource_set import ( constraint_element_to_resource_set_dto_list, - is_set_constraint, ) from pcs.lib.cib.rule import RuleInEffectEval from pcs.lib.cib.rule.cib_to_dto import rule_element_to_dto diff --git a/pcs/lib/cib/constraint/order.py b/pcs/lib/cib/constraint/order.py index 5042d2b0b..9f68861a6 100644 --- a/pcs/lib/cib/constraint/order.py +++ b/pcs/lib/cib/constraint/order.py @@ -13,9 +13,9 @@ from pcs.common.reports.item import ReportItem from pcs.lib.cib.const import TAG_CONSTRAINT_ORDER as TAG from pcs.lib.cib.constraint import constraint +from pcs.lib.cib.constraint.common import is_set_constraint from pcs.lib.cib.constraint.resource_set import ( constraint_element_to_resource_set_dto_list, - is_set_constraint, ) from pcs.lib.cib.tools import check_new_id_applicable from pcs.lib.errors import LibraryError diff --git a/pcs/lib/cib/constraint/resource_set.py b/pcs/lib/cib/constraint/resource_set.py index 2aacd41f0..16ae1edde 100644 --- a/pcs/lib/cib/constraint/resource_set.py +++ b/pcs/lib/cib/constraint/resource_set.py @@ -37,7 +37,7 @@ def prepare_set( ).has_errors: raise LibraryError() return { - "ids": [find_valid_id(id) for id in resource_set["ids"]], + "ids": [find_valid_id(id_) for id_ in resource_set["ids"]], "options": resource_set["options"], } @@ -112,10 +112,6 @@ def is_resource_in_same_group(cib, resource_id_list): ) -def is_set_constraint(constraint_el: etree._Element) -> bool: - return constraint_el.find("./resource_set") is not None - - def _resource_set_element_to_dto( resource_set_el: etree._Element, ) -> CibResourceSetDto: diff --git a/pcs/lib/cib/constraint/ticket.py b/pcs/lib/cib/constraint/ticket.py index bd2fbcebd..6a7eaddd8 100644 --- a/pcs/lib/cib/constraint/ticket.py +++ b/pcs/lib/cib/constraint/ticket.py @@ -23,9 +23,9 @@ from pcs.lib.cib import tools from pcs.lib.cib.const import TAG_CONSTRAINT_TICKET as TAG from pcs.lib.cib.constraint import constraint +from pcs.lib.cib.constraint.common import is_set_constraint from pcs.lib.cib.constraint.resource_set import ( constraint_element_to_resource_set_dto_list, - is_set_constraint, ) from pcs.lib.cib.tools import role_constructor from pcs.lib.errors import LibraryError @@ -154,8 +154,7 @@ def prepare_options_plain( validate.NamesIn( # rsc and rsc-ticket are passed as parameters not as items in the # options dict - (set(ATTRIB) | set(ATTRIB_PLAIN) | {"id"}) - - {"rsc", "ticket"} + (set(ATTRIB) | set(ATTRIB_PLAIN) | {"id"}) - {"rsc", "ticket"} ).validate(options) ) diff --git a/pcs/lib/cib/fencing_topology.py b/pcs/lib/cib/fencing_topology.py index 2434723bc..8ca01a70b 100644 --- a/pcs/lib/cib/fencing_topology.py +++ b/pcs/lib/cib/fencing_topology.py @@ -1,30 +1,54 @@ +from collections.abc import Sequence from typing import ( + Any, + Final, Optional, - Sequence, - Set, + Type, + TypeVar, + cast, ) from lxml import etree -from lxml.etree import _Element +from lxml.etree import ( + _Attrib, + _Element, +) from pcs.common import reports from pcs.common.fencing_topology import ( TARGET_TYPE_ATTRIBUTE, TARGET_TYPE_NODE, TARGET_TYPE_REGEXP, + FencingTargetType, + FencingTargetValue, +) +from pcs.common.pacemaker.fencing_topology import ( + CibFencingLevelAttributeDto, + CibFencingLevelNodeDto, + CibFencingLevelRegexDto, + CibFencingTopologyDto, ) from pcs.common.reports import ( ReportItemList, ReportItemSeverity, ReportProcessor, + has_errors, ) from pcs.common.reports import codes as report_codes -from pcs.common.reports import has_errors from pcs.common.reports.item import ReportItem -from pcs.common.types import StringCollection +from pcs.common.types import StringSequence from pcs.common.validate import is_integer -from pcs.lib.cib.resource.stonith import is_stonith_resource -from pcs.lib.cib.tools import find_unique_id +from pcs.lib.cib.const import TAG_FENCING_LEVEL +from pcs.lib.cib.resource.stonith import is_stonith +from pcs.lib.cib.tools import ( + ElementNotFound, + IdProvider, + get_element_by_id, + get_fencing_topology, + multivalue_attr_contains_value, + multivalue_attr_delete_value, + multivalue_attr_has_any_values, +) from pcs.lib.errors import LibraryError from pcs.lib.pacemaker.state import _Element as StateElement from pcs.lib.pacemaker.values import ( @@ -32,43 +56,75 @@ validate_id, ) +_DEVICES_ATTRIBUTE: Final = "devices" + +FencingLevelDto = TypeVar( + "FencingLevelDto", + CibFencingLevelAttributeDto, + CibFencingLevelNodeDto, + CibFencingLevelRegexDto, +) + + +def _generate_level_id( + id_provider: IdProvider, + level: str, + target_type: FencingTargetType, + target_value: FencingTargetValue, +) -> str: + if target_type == TARGET_TYPE_ATTRIBUTE: + # For attribute target type, the value is tuple[str, str] + id_part = target_value[0] + else: + # For all other target types, the value is str + id_part = str(target_value) + return id_provider.allocate_id(sanitize_id(f"fl-{id_part}-{level}")) + -def add_level( +def add_level( # noqa: PLR0913 reporter: ReportProcessor, - topology_el: _Element, - resources_el: _Element, - level, - target_type, - target_value, - devices, + cib: _Element, + level: str, + target_type: FencingTargetType, + target_value: FencingTargetValue, + devices: StringSequence, cluster_status_nodes: Sequence[StateElement], - force_device=False, - force_node=False, -): + level_id: Optional[str] = None, + force_device: bool = False, + force_node: bool = False, +) -> None: """ Validate and add a new fencing level. Raise LibraryError if not valid. reporter -- report processor - etree topology_el -- etree element to add the level to - etree resources_el -- etree element with resources definitions - int|string level -- level (index) of the new fencing level - constant target_type -- the new fencing level target value type - mixed target_value -- the new fencing level target value - Iterable devices -- list of stonith devices for the new fencing level - Iterable cluster_status_nodes -- list of status of existing cluster nodes - bool force_device -- continue even if a stonith device does not exist - bool force_node -- continue even if a node (target) does not exist + cib -- the whole cib + level -- level (index) of the new fencing level + target_type -- the new fencing level target value type + target_value -- the new fencing level target value + devices -- list of stonith devices for the new fencing level + cluster_status_nodes -- list of status of existing cluster nodes + level_id -- user specified id for the level element + force_device -- continue even if a stonith device does not exist + force_node -- continue even if a node (target) does not exist """ # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments + id_provider = IdProvider(cib) + validate_id_reports: ReportItemList = [] + if level_id is not None: + validate_id(level_id, "fencing level id", reporter=validate_id_reports) reporter.report_list( - _validate_level(level) + (id_provider.book_ids(level_id) if level_id else []) + + validate_id_reports + + _validate_level(level) + _validate_target( cluster_status_nodes, target_type, target_value, force_node ) - + _validate_devices(resources_el, devices, force_device) + + _validate_devices(cib, devices, force_device) ) if reporter.has_errors: raise LibraryError() + topology_el = get_fencing_topology(cib) reporter.report_list( _validate_level_target_devices_does_not_exist( topology_el, level, target_type, target_value, devices @@ -77,14 +133,22 @@ def add_level( if reporter.has_errors: raise LibraryError() _append_level_element( - topology_el, int(level), target_type, target_value, devices + topology_el, + level, + target_type, + target_value, + devices, + ( + level_id + or _generate_level_id(id_provider, level, target_type, target_value) + ), ) -def remove_all_levels(topology_el): +def remove_all_levels(topology_el: _Element) -> None: """ Remove all fencing levels. - etree topology_el -- etree element to remove the levels from + topology_el -- etree element to remove the levels from """ # Do not ever remove a fencing-topology element, even if it is empty. There # may be ACLs set in pacemaker which allow "write" for fencing-level @@ -93,17 +157,17 @@ def remove_all_levels(topology_el): # the whole change to be rejected by pacemaker with a "permission denied" # message. # https://bugzilla.redhat.com/show_bug.cgi?id=1642514 - for level_el in topology_el.findall("fencing-level"): - level_el.getparent().remove(level_el) + for level_el in topology_el.findall(TAG_FENCING_LEVEL): + # Parent is guaranteed by CIB schema + cast(_Element, level_el.getparent()).remove(level_el) def remove_levels_by_params( topology_el: _Element, - level=None, - # TODO create a special type, so that it cannot accept any string - target_type: Optional[str] = None, - target_value=None, - devices: Optional[StringCollection] = None, + level: Optional[str] = None, + target_type: Optional[FencingTargetType] = None, + target_value: Optional[FencingTargetValue] = None, + devices: Optional[StringSequence] = None, # TODO remove, deprecated backward compatibility layer ignore_if_missing: bool = False, # TODO remove, deprecated backward compatibility layer @@ -113,9 +177,9 @@ def remove_levels_by_params( Remove specified fencing level(s) topology_el -- etree element to remove the levels from - int|string level -- level (index) of the fencing level to remove + level -- level (index) of the fencing level to remove target_type -- the removed fencing level target value type - mixed target_value -- the removed fencing level target value + target_value -- the removed fencing level target value devices -- list of stonith devices of the removed fencing level ignore_if_missing -- when True, do not report if level not found """ @@ -148,7 +212,7 @@ def remove_levels_by_params( report_list.append( ReportItem.error( reports.messages.CibFencingLevelDoesNotExist( - level, + level or "", target_type, target_value, sorted(devices) if devices else [], @@ -158,37 +222,51 @@ def remove_levels_by_params( if has_errors(report_list): return report_list for el in level_el_list: - el.getparent().remove(el) + # Parent guaranteed by CIB schema + cast(_Element, el.getparent()).remove(el) return report_list -def remove_device_from_all_levels(topology_el, device_id): +def find_levels_with_device( + topology_el: _Element, device_id: str +) -> list[_Element]: """ - Remove specified stonith device from all fencing levels. + Return list of all fencing-level elements that reference the specified + device - etree topology_el -- etree element with levels to remove the device from - string device_id -- stonith device to remove + topology_el -- etree element with fencing levels + device_id -- id of the stonith device """ - # Do not ever remove a fencing-topology element, even if it is empty. There - # may be ACLs set in pacemaker which allow "write" for fencing-level - # elements (adding, changing and removing) but not fencing-topology - # elements. In such a case, removing a fencing-topology element would cause - # the whole change to be rejected by pacemaker with a "permission denied" - # message. - # https://bugzilla.redhat.com/show_bug.cgi?id=1642514 - for level_el in topology_el.findall("fencing-level"): - new_devices = [ - dev - for dev in level_el.get("devices").split(",") - if dev != device_id - ] - if new_devices: - level_el.set("devices", ",".join(new_devices)) - else: - level_el.getparent().remove(level_el) - - -def export(topology_el): + return [ + level_el + for level_el in topology_el.findall(TAG_FENCING_LEVEL) + if multivalue_attr_contains_value( + level_el, _DEVICES_ATTRIBUTE, device_id + ) + ] + + +def remove_device_from_level(level_el: _Element, device_id: str) -> None: + """ + Remove specified stonith device from fencing level. + + level_el -- level element from which the device is removed + device_id -- stonith device to remove + """ + multivalue_attr_delete_value(level_el, _DEVICES_ATTRIBUTE, device_id) + + +def has_any_devices(level_el: _Element) -> bool: + """ + Return whether there are any devices references in the fencing level + + level_el -- fencing level element + """ + return multivalue_attr_has_any_values(level_el, _DEVICES_ATTRIBUTE) + + +# DEPRECATED, use fencing_topology_el_to_dto +def export(topology_el: _Element) -> list[dict[str, Any]]: """ Export all fencing levels. @@ -198,8 +276,9 @@ def export(topology_el): etree topology_el -- etree element to export """ export_levels = [] - for level_el in topology_el.iterfind("fencing-level"): - target_type = target_value = None + for level_el in topology_el.iterfind(TAG_FENCING_LEVEL): + target_type: Optional[FencingTargetType] = None + target_value: Optional[FencingTargetValue] = None if "target" in level_el.attrib: target_type = TARGET_TYPE_NODE target_value = level_el.get("target") @@ -209,8 +288,8 @@ def export(topology_el): elif "target-attribute" in level_el.attrib: target_type = TARGET_TYPE_ATTRIBUTE target_value = ( - level_el.get("target-attribute"), - level_el.get("target-value"), + str(level_el.attrib["target-attribute"]), + str(level_el.attrib["target-value"]), ) if target_type and target_value: export_levels.append( @@ -218,38 +297,101 @@ def export(topology_el): "target_type": target_type, "target_value": target_value, "level": level_el.get("index"), - "devices": level_el.get("devices").split(","), + "devices": str(level_el.attrib[_DEVICES_ATTRIBUTE]).split( + "," + ), } ) return export_levels +def _fencing_level_dto_factory( + dto_type: Type[FencingLevelDto], + level_el_attrib: _Attrib, +) -> FencingLevelDto: + if dto_type is CibFencingLevelRegexDto: + target_args = {"target_pattern": str(level_el_attrib["target-pattern"])} + elif dto_type is CibFencingLevelAttributeDto: + target_args = { + "target_attribute": str(level_el_attrib["target-attribute"]), + "target_value": str(level_el_attrib["target-value"]), + } + else: + target_args = {"target": str(level_el_attrib["target"])} + + return dto_type( + id=str(level_el_attrib["id"]), + index=int(level_el_attrib["index"]), + devices=[ + device.strip() + for device in str(level_el_attrib["devices"]).split(",") + ], + **target_args, + ) + + +def fencing_topology_el_to_dto( + fencing_topology_el: _Element, +) -> CibFencingTopologyDto: + level_el_list = _find_all_level_elements(fencing_topology_el) + target_node_list = [] + target_regex_list = [] + target_attr_list = [] + for level_el in level_el_list: + if "target" in level_el.attrib: + target_node_list.append( + _fencing_level_dto_factory( + CibFencingLevelNodeDto, + level_el.attrib, + ) + ) + if "target-pattern" in level_el.attrib: + target_regex_list.append( + _fencing_level_dto_factory( + CibFencingLevelRegexDto, + level_el.attrib, + ) + ) + if "target-attribute" in level_el.attrib: + target_attr_list.append( + _fencing_level_dto_factory( + CibFencingLevelAttributeDto, + level_el.attrib, + ) + ) + + return CibFencingTopologyDto( + target_node=target_node_list, + target_regex=target_regex_list, + target_attribute=target_attr_list, + ) + + def verify( - topology_el: _Element, - resources_el: _Element, + cib: _Element, cluster_status_nodes: Sequence[StateElement], ) -> ReportItemList: """ Check if all cluster nodes and stonith devices used in fencing levels exist. - topology_el -- fencing levels to check - resources_el -- resources definitions + cib -- the whole cib cluster_status_nodes -- list of status of existing cluster nodes """ report_list: ReportItemList = [] - used_nodes: Set[str] = set() - used_devices: Set[str] = set() + used_nodes: set[str] = set() + used_devices: set[str] = set() + topology_el = get_fencing_topology(cib) - for level_el in topology_el.iterfind("fencing-level"): - used_devices.update(str(level_el.get("devices", "")).split(",")) + for level_el in topology_el.iterfind(TAG_FENCING_LEVEL): + used_devices.update( + str(level_el.get(_DEVICES_ATTRIBUTE, "")).split(",") + ) if "target" in level_el.attrib: used_nodes.add(str(level_el.get("target", ""))) if used_devices: report_list.extend( - _validate_devices( - resources_el, sorted(used_devices), allow_force=False - ) + _validate_devices(cib, sorted(used_devices), allow_force=False) ) for node in sorted(used_nodes): @@ -261,12 +403,12 @@ def verify( return report_list -def _validate_level(level) -> ReportItemList: +def _validate_level(level: str) -> ReportItemList: report_list: ReportItemList = [] if not is_integer(level, 1, 9): report_list.append( ReportItem.error( - reports.messages.InvalidOptionValue("level", level, "1..9") + reports.messages.InvalidOptionValue("level", str(level), "1..9") ) ) return report_list @@ -274,16 +416,16 @@ def _validate_level(level) -> ReportItemList: def _validate_target( cluster_status_nodes: Sequence[StateElement], - target_type, - target_value, - force_node=False, + target_type: FencingTargetType, + target_value: FencingTargetValue, + force_node: bool = False, ) -> ReportItemList: return _validate_target_typewise(target_type) + _validate_target_valuewise( cluster_status_nodes, target_type, target_value, force_node ) -def _validate_target_typewise(target_type) -> ReportItemList: +def _validate_target_typewise(target_type: FencingTargetType) -> ReportItemList: report_list: ReportItemList = [] if target_type not in [ TARGET_TYPE_NODE, @@ -303,10 +445,10 @@ def _validate_target_typewise(target_type) -> ReportItemList: def _validate_target_valuewise( cluster_status_nodes: Sequence[StateElement], - target_type, - target_value, - force_node=False, - allow_force=True, + target_type: FencingTargetType, + target_value: FencingTargetValue, + force_node: bool = False, + allow_force: bool = True, ) -> ReportItemList: report_list: ReportItemList = [] if target_type == TARGET_TYPE_NODE: @@ -330,14 +472,20 @@ def _validate_target_valuewise( else report_codes.FORCE ), ), - message=reports.messages.NodeNotFound(target_value), + message=reports.messages.NodeNotFound( + # This is a str based on target_type + str(target_value) + ), ) ) return report_list def _validate_devices( - resources_el: _Element, devices, force_device=False, allow_force=True + cib: _Element, + devices: StringSequence, + force_device: bool = False, + allow_force: bool = True, ) -> ReportItemList: report_list: ReportItemList = [] if not devices: @@ -355,8 +503,10 @@ def _validate_devices( report_list.extend(validate_id_report_list) if has_errors(validate_id_report_list): continue - # TODO use the new finding function - if not is_stonith_resource(resources_el, dev): + try: + if not is_stonith(get_element_by_id(cib, dev)): + invalid_devices.append(dev) + except ElementNotFound: invalid_devices.append(dev) if invalid_devices: report_list.append( @@ -382,54 +532,72 @@ def _validate_devices( def _validate_level_target_devices_does_not_exist( - tree, level, target_type, target_value, devices + tree: _Element, + level: str, + target_type: FencingTargetType, + target_value: FencingTargetValue, + devices: StringSequence, ) -> ReportItemList: report_list: ReportItemList = [] if _find_level_elements(tree, level, target_type, target_value, devices): report_list.append( ReportItem.error( reports.messages.CibFencingLevelAlreadyExists( - level, target_type, target_value, devices + level, target_type, target_value, list(devices) ) ) ) return report_list -def _append_level_element(tree, level, target_type, target_value, devices): +def _append_level_element( + tree: _Element, + level: str, + target_type: FencingTargetType, + target_value: FencingTargetValue, + devices: StringSequence, + level_id: str, +) -> _Element: level_el = etree.SubElement( - tree, "fencing-level", index=str(level), devices=",".join(devices) + tree, + TAG_FENCING_LEVEL, + index=level, + devices=",".join(devices), + id=level_id, ) if target_type == TARGET_TYPE_NODE: - level_el.set("target", target_value) - id_part = target_value + level_el.set("target", str(target_value)) elif target_type == TARGET_TYPE_REGEXP: - level_el.set("target-pattern", target_value) - id_part = target_value + level_el.set("target-pattern", str(target_value)) elif target_type == TARGET_TYPE_ATTRIBUTE: level_el.set("target-attribute", target_value[0]) level_el.set("target-value", target_value[1]) - id_part = target_value[0] - level_el.set( - "id", - find_unique_id(tree, sanitize_id(f"fl-{id_part}-{level}")), - ) return level_el +def _find_all_level_elements(tree: _Element) -> list[_Element]: + return tree.findall(TAG_FENCING_LEVEL) + + def _find_level_elements( - tree, level=None, target_type=None, target_value=None, devices=None -): - xpath_vars = {} + tree: _Element, + level: Optional[str] = None, + target_type: Optional[FencingTargetType] = None, + target_value: Optional[FencingTargetValue] = None, + devices: Optional[StringSequence] = None, +) -> list[_Element]: + xpath_vars: dict[str, str] = {} xpath_target = "" if target_type and target_value: if target_type == TARGET_TYPE_NODE: xpath_target = "@target=$var_target" - xpath_vars["var_target"] = target_value + # The value of xpath_target determines that this is a string + xpath_vars["var_target"] = str(target_value) elif target_type == TARGET_TYPE_REGEXP: xpath_target = "@target-pattern=$var_target_pattern" - xpath_vars["var_target_pattern"] = target_value + # The value of xpath_target determines that this is a string + xpath_vars["var_target_pattern"] = str(target_value) elif target_type == TARGET_TYPE_ATTRIBUTE: xpath_target = ( "@target-attribute=$var_target_attribute " @@ -449,9 +617,10 @@ def _find_level_elements( ) if xpath_attrs: return tree.xpath( - f"fencing-level[{xpath_attrs}]", + f"{TAG_FENCING_LEVEL}[{xpath_attrs}]", var_devices=(",".join(devices) if devices else ""), - var_level=level, - **xpath_vars, + var_level=level or "", + **xpath_vars, # type: ignore ) - return tree.findall("fencing-level") + + return _find_all_level_elements(tree) diff --git a/pcs/lib/cib/node.py b/pcs/lib/cib/node.py index efa244aa7..6d4be8e7c 100644 --- a/pcs/lib/cib/node.py +++ b/pcs/lib/cib/node.py @@ -1,11 +1,13 @@ from collections import namedtuple -from typing import Set +from typing import Optional, Set from lxml import etree from lxml.etree import _Element from pcs.common import reports +from pcs.common.pacemaker.node import CibNodeDto from pcs.common.reports.item import ReportItem +from pcs.lib.cib import nvpair_multi, rule from pcs.lib.cib.nvpair import update_nvset from pcs.lib.cib.tools import get_nodes from pcs.lib.errors import LibraryError @@ -14,6 +16,38 @@ get_root, ) +TAG_NODE = "node" + + +def get_all_node_elements(nodes_section: _Element) -> list[_Element]: + return nodes_section.findall(TAG_NODE) + + +def node_el_to_dto( + node_el: _Element, rule_eval: Optional[rule.RuleInEffectEval] = None +) -> CibNodeDto: + if rule_eval is None: + rule_eval = rule.RuleInEffectEvalDummy() + return CibNodeDto( + id=str(node_el.attrib["id"]), + uname=str(node_el.attrib["uname"]), + description=node_el.get("description"), + score=node_el.get("score"), + type=node_el.get("type"), + instance_attributes=[ + nvpair_multi.nvset_element_to_dto(nvset, rule_eval) + for nvset in nvpair_multi.find_nvsets( + node_el, nvpair_multi.NVSET_INSTANCE + ) + ], + utilization=[ + nvpair_multi.nvset_element_to_dto(nvset, rule_eval) + for nvset in nvpair_multi.find_nvsets( + node_el, nvpair_multi.NVSET_UTILIZATION + ) + ], + ) + class PacemakerNode(namedtuple("PacemakerNode", "name addr")): """ @@ -21,6 +55,8 @@ class PacemakerNode(namedtuple("PacemakerNode", "name addr")): communication and checking if node name / address is in use. """ + __slots__ = () + def update_node_instance_attrs( cib, id_provider, node_name, attrs, state_nodes=None diff --git a/pcs/lib/cib/node_rename.py b/pcs/lib/cib/node_rename.py new file mode 100644 index 000000000..41072ed13 --- /dev/null +++ b/pcs/lib/cib/node_rename.py @@ -0,0 +1,226 @@ +from typing import Callable + +from lxml.etree import _Element + +from pcs.common import reports +from pcs.lib.cib.const import ( + TAG_CONSTRAINT_LOCATION, + TAG_FENCING_LEVEL, + TAG_RESOURCE_PRIMITIVE, + TAG_RULE, +) +from pcs.lib.cib.sections import ACLS +from pcs.lib.xml_tools import find_parent + + +def rename_in_cib( + cib: _Element, old_name: str, new_name: str +) -> tuple[bool, reports.ReportItemList]: + cib_updated, report_list = _reduce_update_results( + _rename_node_in_locations(cib, old_name, new_name), + _rename_node_in_rules(cib, old_name, new_name), + _rename_node_in_fencing_levels(cib, old_name, new_name), + _rename_node_in_fence_devices(cib, old_name, new_name), + ) + + report_list.extend(_warn_about_fencing_level_patterns(cib)) + report_list.extend(_warn_about_acls(cib)) + + return cib_updated, report_list + + +def _rename_node_in_locations( + cib: _Element, old_name: str, new_name: str +) -> tuple[bool, reports.ReportItemList]: + report_list: reports.ReportItemList = [] + cib_updated = False + for location in cib.findall( + f".//{TAG_CONSTRAINT_LOCATION}[@node='{old_name}']" + ): + location.set("node", new_name) + cib_updated = True + report_list.append( + reports.ReportItem.info( + reports.messages.CibNodeRenameElementUpdated( + element_type=TAG_CONSTRAINT_LOCATION, + element_id=str(location.get("id", "")), + attribute_desc="node", + old_value=old_name, + new_value=new_name, + ) + ) + ) + return cib_updated, report_list + + +def _rename_node_in_rules( + cib: _Element, old_name: str, new_name: str +) -> tuple[bool, reports.ReportItemList]: + report_list: reports.ReportItemList = [] + cib_updated = False + for expression in cib.findall( + f".//{TAG_RULE}/expression[@attribute='#uname'][@value='{old_name}']" + ): + expression.set("value", new_name) + cib_updated = True + rule = expression.getparent() + # mypy would complain but: //{TAG_RULE}/expression + assert rule is not None + report_list.append( + reports.ReportItem.info( + reports.messages.CibNodeRenameElementUpdated( + element_type=TAG_RULE, + element_id=str(rule.get("id", "")), + attribute_desc="#uname expression", + old_value=old_name, + new_value=new_name, + ) + ) + ) + return cib_updated, report_list + + +def _rename_node_in_fencing_levels( + cib: _Element, old_name: str, new_name: str +) -> tuple[bool, reports.ReportItemList]: + report_list: reports.ReportItemList = [] + cib_updated = False + for level in cib.findall(f".//{TAG_FENCING_LEVEL}"): + level_id = str(level.get("id", "")) + if level.get("target") == old_name: + level.set("target", new_name) + cib_updated = True + report_list.append( + reports.ReportItem.info( + reports.messages.CibNodeRenameElementUpdated( + element_type=TAG_FENCING_LEVEL, + element_id=level_id, + attribute_desc="target", + old_value=old_name, + new_value=new_name, + ) + ) + ) + elif ( + level.get("target-attribute") == "#uname" + and level.get("target-value") == old_name + ): + level.set("target-value", new_name) + cib_updated = True + report_list.append( + reports.ReportItem.info( + reports.messages.CibNodeRenameElementUpdated( + element_type=TAG_FENCING_LEVEL, + element_id=level_id, + attribute_desc="target-value for target-attribute=#uname", + old_value=old_name, + new_value=new_name, + ) + ) + ) + return cib_updated, report_list + + +def _rename_in_host_list(value: str, old_name: str, new_name: str) -> str: + return ",".join( + [new_name if host == old_name else host for host in value.split(",")] + ) + + +def _rename_in_host_map(value: str, old_name: str, new_name: str) -> str: + # Format: node:port[,port];node:port + new_entries = [] + for entry in value.split(";"): + if ":" not in entry: + # It's broken but if it's an old domain name, it can be confused + # with host_list. We cannot fix it but it makes sense to update + # host name if it exactly match with the old name. + new_entries.append(new_name if entry == old_name else entry) + continue + host, ports = entry.split(":", 1) + if host == old_name: + host = new_name + new_entries.append(f"{host}:{ports}") + return ";".join(new_entries) + + +def _rename_node_in_fence_devices_attribute( + cib: _Element, + attr_name: str, + rename_func: Callable[[str, str, str], str], + old_name: str, + new_name: str, +) -> tuple[bool, reports.ReportItemList]: + report_list: reports.ReportItemList = [] + cib_updated = False + for nvpair in cib.findall( + f".//{TAG_RESOURCE_PRIMITIVE}[@class='stonith']/instance_attributes" + f"/nvpair[@name='{attr_name}']" + ): + old_value = str(nvpair.get("value", "")) + new_value = rename_func(old_value, old_name, new_name) + if new_value != old_value: + nvpair.set("value", new_value) + cib_updated = True + fence_device = find_parent(nvpair, [TAG_RESOURCE_PRIMITIVE]) + # mypy would complain but: + # //{TAG_RESOURCE_PRIMITIVE}/instance_attributes/nvpair + assert fence_device is not None + report_list.append( + reports.ReportItem.info( + reports.messages.CibNodeRenameElementUpdated( + element_type=TAG_RESOURCE_PRIMITIVE, + element_id=fence_device.get("id", ""), + attribute_desc=attr_name, + old_value=old_value, + new_value=new_value, + ) + ) + ) + return cib_updated, report_list + + +def _rename_node_in_fence_devices( + cib: _Element, old_name: str, new_name: str +) -> tuple[bool, reports.ReportItemList]: + return _reduce_update_results( + _rename_node_in_fence_devices_attribute( + cib, "pcmk_host_list", _rename_in_host_list, old_name, new_name + ), + _rename_node_in_fence_devices_attribute( + cib, "pcmk_host_map", _rename_in_host_map, old_name, new_name + ), + ) + + +def _warn_about_fencing_level_patterns( + cib: _Element, +) -> reports.ReportItemList: + # This can be reported multiple times. It makes sense since the pattern is + # reported and the user can immediately consider a manual intervention + # necessity. + return [ + reports.ReportItem.warning( + reports.messages.CibNodeRenameFencingLevelPatternExists( + level_id=str(element.get("id", "")), + pattern=str(element.get("target-pattern", "")), + ) + ) + for element in cib.findall(f".//{TAG_FENCING_LEVEL}[@target-pattern]") + ] + + +def _warn_about_acls(cib: _Element) -> reports.ReportItemList: + acl_section = cib.find(f".//{ACLS}") + return ( + [reports.ReportItem.warning(reports.messages.CibNodeRenameAclsExist())] + if acl_section is not None and len(acl_section) > 0 + else [] + ) + + +def _reduce_update_results( + *updates: tuple[bool, reports.ReportItemList], +) -> tuple[bool, reports.ReportItemList]: + updates, report_lists = zip(*updates) + return any(updates), [r for rlist in report_lists for r in rlist] diff --git a/pcs/lib/cib/nvpair.py b/pcs/lib/cib/nvpair.py index 7e96f944f..2ba770fc8 100644 --- a/pcs/lib/cib/nvpair.py +++ b/pcs/lib/cib/nvpair.py @@ -148,7 +148,7 @@ def update_nvset(nvset_element, nvpair_dict, id_provider): set_nvpair_in_nvset(nvset_element, name, value, id_provider) -def get_nvset(nvset): +def get_nvset(nvset: _Element) -> list[dict[str, Optional[str]]]: """ Returns nvset element as list of nvpairs with format: [ @@ -162,16 +162,14 @@ def get_nvset(nvset): nvset -- nvset element """ - nvpair_list = [] - for nvpair in nvset.findall("./nvpair"): - nvpair_list.append( - { - "id": nvpair.get("id"), - "name": nvpair.get("name"), - "value": nvpair.get("value", ""), - } - ) - return nvpair_list + return [ + { + "id": nvpair.get("id"), + "name": nvpair.get("name"), + "value": nvpair.get("value"), + } + for nvpair in nvset.findall("./nvpair") + ] @overload diff --git a/pcs/lib/cib/nvpair_multi.py b/pcs/lib/cib/nvpair_multi.py index efb78d75e..e569c245e 100644 --- a/pcs/lib/cib/nvpair_multi.py +++ b/pcs/lib/cib/nvpair_multi.py @@ -85,6 +85,30 @@ def nvset_element_to_dto( ) +def nvset_to_dict(nvset_el: _Element) -> dict[str, Optional[str]]: + """ + Export only nvpairs from an nvset xml element into a dictionary + """ + return { + str(nvpair_el.attrib["name"]): nvpair_el.get("value") + for nvpair_el in nvset_el.iterfind("./nvpair") + } + + +def nvset_to_dict_except_without_values( + nvset_el: _Element, +) -> dict[str, str]: + """ + Value in a nvpair is not mandatory. This function ignores such entries in + the same way as Pacemaker does. + """ + return { + key: value + for key, value in nvset_to_dict(nvset_el).items() + if value is not None + } + + def find_nvsets(parent_element: _Element, tag: NvsetTag) -> List[_Element]: """ Get all nvset xml elements in the given parent element @@ -119,8 +143,9 @@ def find_nvsets_by_ids( parent_element, element_type_desc="options set", ) - if searcher.element_found(): - element_list.append(searcher.get_element()) + found_element = searcher.get_element() + if found_element is not None: + element_list.append(found_element) else: report_list.extend(searcher.get_errors()) return element_list, report_list diff --git a/pcs/lib/cib/remove_elements.py b/pcs/lib/cib/remove_elements.py new file mode 100644 index 000000000..47834229c --- /dev/null +++ b/pcs/lib/cib/remove_elements.py @@ -0,0 +1,613 @@ +from collections import defaultdict +from dataclasses import dataclass +from itertools import chain +from typing import ( + Iterable, + Mapping, + Sequence, + cast, +) + +from lxml import etree +from lxml.etree import _Element + +from pcs.common import reports +from pcs.common.resource_status import ( + MoreChildrenQuantifierType, + ResourcesStatusFacade, + ResourceState, +) +from pcs.common.types import ( + StringCollection, + StringSequence, +) +from pcs.lib.cib import const +from pcs.lib.cib.constraint.common import ( + is_constraint, + is_set_constraint, +) +from pcs.lib.cib.constraint.location import ( + is_location_constraint, + is_location_rule, +) +from pcs.lib.cib.fencing_topology import ( + find_levels_with_device, + has_any_devices, + remove_device_from_level, +) +from pcs.lib.cib.resource.clone import is_any_clone +from pcs.lib.cib.resource.common import ( + disable, + get_inner_resources, + is_resource, +) +from pcs.lib.cib.resource.group import is_group +from pcs.lib.cib.resource.guest_node import ( + get_node_name_from_resource as get_node_name_from_guest, +) +from pcs.lib.cib.resource.guest_node import is_guest_node +from pcs.lib.cib.resource.remote_node import ( + get_node_name_from_resource as get_node_name_from_remote, +) +from pcs.lib.cib.tag import is_tag +from pcs.lib.cib.tools import ( + ElementNotFound, + IdProvider, + find_elements_referencing_id, + find_location_constraints_referencing_node_name, + get_element_by_id, + get_elements_by_ids, + get_fencing_topology, + remove_element_by_id, + remove_one_element, +) +from pcs.lib.pacemaker.live import parse_cib_xml +from pcs.lib.pacemaker.state import ( + ensure_resource_state, + is_resource_managed, +) +from pcs.lib.pacemaker.status import ( + ClusterStatusParser, + ClusterStatusParsingError, + cluster_status_parsing_error_to_report, +) +from pcs.lib.xml_tools import get_root + + +@dataclass(frozen=True) +class DependantElements: + id_tag_map: dict[str, str] + + def to_reports(self) -> reports.ReportItemList: + if not self.id_tag_map: + return [] + + return [ + reports.ReportItem.info( + reports.messages.CibRemoveDependantElements(self.id_tag_map) + ) + ] + + +@dataclass(frozen=True) +class ElementReferences: + reference_map: dict[str, set[str]] + id_tag_map: dict[str, str] + + def to_reports(self) -> reports.ReportItemList: + if not self.reference_map: + return [] + + return [ + reports.ReportItem.info( + reports.messages.CibRemoveReferences( + self.id_tag_map, self.reference_map + ) + ) + ] + + +@dataclass(frozen=True) +class UnsupportedElements: + id_tag_map: Mapping[str, str] + supported_element_types: StringCollection + + +class ElementsToRemove: + """ + Find ids of all elements that should be removed. This function is aware + of relations and references between elements and will also return ids of + elements that are somehow referencing elements with specified ids. + + cib -- the whole cib + ids -- ids of configuration elements to remove + """ + + def __init__(self, cib: _Element, ids: StringCollection): + wip_cib = parse_cib_xml(etree.tostring(cib).decode()) + + initial_ids = set(ids) + elements_to_process, missing_ids = get_elements_by_ids( + wip_cib, initial_ids + ) + + supported_elements, unsupported_elements = _validate_element_types( + elements_to_process + ) + + element_ids_to_remove, removing_references_from = ( + _get_dependencies_to_remove(supported_elements) + ) + + # We need to use ids of the elements, since we will work with cib, but + # the the elements were instantiated using the wip_cib, which means we + # cannot reuse the elements + self._ids_to_remove = element_ids_to_remove + self._dependant_element_ids = self._ids_to_remove - initial_ids + self._missing_ids = set(missing_ids) + self._unsupported_ids = { + str(el.attrib["id"]) for el in unsupported_elements + } + + all_ids = set( + chain( + self._ids_to_remove, + self._unsupported_ids, + *removing_references_from.values(), + ) + ) + self._id_tag_map = { + str(el.attrib["id"]): el.tag + for el in get_elements_by_ids(cib, all_ids)[0] + } + self._element_references = removing_references_from + self._resources_to_remove = [ + el + for el in get_elements_by_ids(cib, sorted(element_ids_to_remove))[0] + if is_resource(el) + ] + + @property + def ids_to_remove(self) -> set[str]: + """ + Ids of ALL cib elements with id that should be removed, including all + resource and dependant elements + """ + return set(self._ids_to_remove) + + @property + def resources_to_remove(self) -> list[_Element]: + """ + List of cib resources that should be removed. Used for operations + needed for resources before their deletion, e.g. disabling them. + """ + return list(self._resources_to_remove) + + @property + def dependant_elements(self) -> DependantElements: + """ + Information about cib elements that are removed indirectly as + dependencies of other removed cib elements + """ + return DependantElements( + { + element_id: self._id_tag_map[element_id] + for element_id in self._dependant_element_ids + } + ) + + @property + def element_references(self) -> ElementReferences: + """ + Information about cib element references that need to be removed. + These references does not have their own id and need special handling + while removing, e.g. references to stonith devices in fencing-level + elements, or obj-ref elements inside tag elements. + """ + return ElementReferences( + dict(self._element_references), + { + element_id: self._id_tag_map[element_id] + for element_id in chain( + self._element_references, + *self._element_references.values(), + ) + }, + ) + + @property + def missing_ids(self) -> set[str]: + """ + Set of ids not present in the cib + """ + return set(self._missing_ids) + + @property + def unsupported_elements(self) -> UnsupportedElements: + """ + Information about cib elements that cannot be removed using this + mechanism + """ + return UnsupportedElements( + id_tag_map={ + element_id: self._id_tag_map[element_id] + for element_id in self._unsupported_ids + }, + # the list of tags should match the validations done in + # _validate_element_types function + supported_element_types=["constraint", "location rule", "resource"], + ) + + +def warn_resource_unmanaged( + state: _Element, resource_ids: StringSequence +) -> reports.ReportItemList: + """ + Warn about unmanaged resources + + state -- state of the cluster + resource_ids -- ids of resources to be checked + """ + report_list: reports.ReportItemList = [] + try: + parser = ClusterStatusParser(state) + try: + status_dto = parser.status_xml_to_dto() + except ClusterStatusParsingError as e: + report_list.append(cluster_status_parsing_error_to_report(e)) + return report_list + report_list.extend(parser.get_warnings()) + + status = ResourcesStatusFacade.from_resources_status_dto(status_dto) + for r_id in resource_ids: + if not status.exists(r_id, None): + # Pacemaker does not put misconfigured resources into cluster + # status and we are unable to check state of such resources. + # This happens for e.g. undle with primitive resource inside and + # no IP address for the bundle specified. We expect the resource + # to be stopped since it is misconfigured. Stopping it again + # even when it is unmanaged should not break anything. + report_list.append( + reports.ReportItem.debug( + reports.messages.ConfiguredResourceMissingInStatus( + r_id, ResourceState.UNMANAGED + ) + ) + ) + elif status.is_state(r_id, None, ResourceState.UNMANAGED): + report_list.append( + reports.ReportItem.warning( + reports.messages.ResourceIsUnmanaged(r_id) + ) + ) + except NotImplementedError: + # TODO remove when issue with bundles in status is fixed + report_list.extend( + reports.ReportItem.warning( + reports.messages.ResourceIsUnmanaged(resource_id) + ) + for resource_id in resource_ids + if not is_resource_managed(state, resource_id) + ) + + return report_list + + +def stop_resources( + cib: _Element, resource_elements: Sequence[_Element] +) -> None: + """ + Stop all resources that are going to be removed. + + cib -- the whole cib + resource_elements -- sequence of elements that should be stopped + """ + provider = IdProvider(cib) + for el in resource_elements: + disable(el, provider) + + +def ensure_resources_stopped( + state: _Element, resource_ids: StringSequence +) -> reports.ReportItemList: + """ + Ensure that all resources that should be stopped are stopped. + + state -- state of the cluster + elements -- elements planned to be removed + """ + not_stopped_ids = [] + report_list: reports.ReportItemList = [] + try: + parser = ClusterStatusParser(state) + try: + status_dto = parser.status_xml_to_dto() + except ClusterStatusParsingError as e: + report_list.append(cluster_status_parsing_error_to_report(e)) + return report_list + report_list.extend(parser.get_warnings()) + + status = ResourcesStatusFacade.from_resources_status_dto(status_dto) + for r_id in resource_ids: + if not status.exists(r_id, None): + # Pacemaker does not put misconfigured resources into cluster + # status and we are unable to check state of such resources. + # This happens for e.g. undle with primitive resource inside and + # no IP address for the bundle specified. We expect the resource + # to be stopped since it is misconfigured. + report_list.append( + reports.ReportItem.debug( + reports.messages.ConfiguredResourceMissingInStatus( + r_id, ResourceState.STOPPED + ) + ) + ) + elif not status.is_state( + r_id, + None, + ResourceState.STOPPED, + instances_quantifier=( + MoreChildrenQuantifierType.ALL + if status.can_have_multiple_instances(r_id) + else None + ), + ): + not_stopped_ids.append(r_id) + except NotImplementedError: + # TODO remove when issue with bundles in status is fixed + not_stopped_ids = [ + resource_id + for resource_id in resource_ids + if ensure_resource_state(False, state, resource_id).severity.level + == reports.item.ReportItemSeverity.ERROR + ] + + if not_stopped_ids: + report_list.append( + reports.ReportItem.error( + reports.messages.CannotStopResourcesBeforeDeleting( + not_stopped_ids + ), + force_code=reports.codes.FORCE, + ) + ) + + return report_list + + +def remove_specified_elements( + cib: _Element, elements: ElementsToRemove +) -> None: + """ + Remove all elements that need to be removed. + + state -- state of the cluster + elements -- elements planned to be removed + """ + for element_id in elements.ids_to_remove: + remove_element_by_id(cib, element_id) + + element_references = elements.element_references + for ( + referenced_id, + referenced_in_ids, + ) in element_references.reference_map.items(): + for element_id in referenced_in_ids: + _remove_element_reference( + cib, + referenced_id, + element_id, + element_references.id_tag_map[element_id], + ) + + +def _validate_element_types( + elements: Iterable[_Element], +) -> tuple[list[_Element], list[_Element]]: + supported_elements = [] + unsupported_elements = [] + + for el in elements: + # valid elements should match the valid tags reported from + # ElementsToRemove.unsupported_elements property + if is_constraint(el) or is_location_rule(el) or is_resource(el): + supported_elements.append(el) + else: + unsupported_elements.append(el) + + return supported_elements, unsupported_elements + + +_REFERENCE_TAG_XPATH_MAP = { + const.TAG_RESOURCE_SET: f"./{const.TAG_RESOURCE_REF}[@id=$referenced_id]", + const.TAG_TAG: f"./{const.TAG_OBJREF}[@id=$referenced_id]", +} + + +def _remove_element_reference( + cib: _Element, + element_id: str, + referenced_in_id: str, + referenced_in_tag: str, +) -> None: + # If element has id, then it was already removed using its id and does not + # need to be removed using this reference mapping. Therefore, we need to + # only remove elements that do not have id here, such as obj_ref and + # resource_ref. + try: + element = get_element_by_id(cib, referenced_in_id) + except ElementNotFound: + return + + if referenced_in_tag == const.TAG_FENCING_LEVEL: + remove_device_from_level(element, element_id) + return + + if referenced_in_tag not in _REFERENCE_TAG_XPATH_MAP: + return + for el in cast( + list[_Element], + element.xpath( + _REFERENCE_TAG_XPATH_MAP[referenced_in_tag], + referenced_id=element_id, + ), + ): + remove_one_element(el) + + +def _get_dependencies_to_remove( + elements: Iterable[_Element], +) -> tuple[set[str], dict[str, set[str]]]: + """ + Get ids of all elements that need to be removed (including specified + elements) together with specified elements based on their relations. + Also return mapping for elements whose references are going to be + deleted from their respective parent elements, without deleting the + parent itself. + + WARNING: this is a destructive operation for elements and their etree. + + elements -- iterable of elements that are planned to be removed + """ + elements_to_process = list(elements) + element_ids_to_remove: set[str] = set() + removing_references_from: dict[str, set[str]] = defaultdict(set) + + while elements_to_process: + el = elements_to_process.pop(0) + element_id = str(el.attrib["id"]) + + # Elements with these tags are only used for referencing other elements. + # The 'id' attribute in these does not represent the id of the element + # itself, but the id of the element that they refer to. + # Therefore, it does not make sense to try finding any references to + # these elements. + if el.tag not in ( + const.TAG_OBJREF, + const.TAG_RESOURCE_REF, + const.TAG_ROLE, + ): + if element_id in element_ids_to_remove: + continue + element_ids_to_remove.add(element_id) + elements_to_process.extend(_get_element_references(el)) + elements_to_process.extend(_get_inner_references(el)) + elements_to_process.extend( + _get_remote_node_name_constraint_references(el) + ) + + for level_el in find_levels_with_device( + get_fencing_topology(get_root(el)), element_id + ): + removing_references_from[element_id].add( + str(level_el.attrib["id"]) + ) + remove_device_from_level(level_el, element_id) + if not has_any_devices(level_el): + elements_to_process.append(level_el) + + parent_el = el.getparent() + if parent_el is not None: + # We only want to remove parent elements that are invalid when + # empty. There may be ACLs set in pacemaker which allow "write" for + # the child elements (adding, changing and removing) but not their + # parent elements. In such case, removing the parent element would + # cause the whole change to be rejected by pacemaker with a + # "permission denied" message. + # https://bugzilla.redhat.com/show_bug.cgi?id=1642514 + if _is_empty_after_inner_el_removal(parent_el): + elements_to_process.append(parent_el) + parent_el.remove(el) + + parent_id = parent_el.get("id") + if parent_id is not None: + removing_references_from[element_id].add(parent_id) + + # Removing references from parent elements that are going to be removed + # (they are present in the 'element_ids_to_remove') is unncesary, since all + # of the child elements are removed when parent is removed. This means we + # can filter out such references from the resulting mapping. + for key in list(removing_references_from): + removing_references_from[key].difference_update(element_ids_to_remove) + if not removing_references_from[key]: + del removing_references_from[key] + + return element_ids_to_remove, removing_references_from + + +def _get_element_references(element: _Element) -> Iterable[_Element]: + """ + Return all CIB elements that are referencing specified element + + element -- references to this element will be + """ + return find_elements_referencing_id(element, str(element.attrib["id"])) + + +def _get_inner_references(element: _Element) -> Iterable[_Element]: + """ + Get all inner elements with attribute id, which means that they might be + referenced in IDREF. Elements with attribute id and type IDREF are also + returned. + """ + # return cast(Iterable[_Element], element.xpath("./*[@id]")) + if is_resource(element): + try: + # we are removing elements from the tree, therefore assertions of + # this function can fail + return get_inner_resources(element) + except IndexError: + return [] + # if element.tag == "alert": + # return element.findall("recipient") + # if is_set_constraint(element): + # return element.findall("resource_set") + # if element.tag == "acl_role": + # return element.findall("acl_permission") + return [] + + +def _is_last_element(parent_element: _Element, child_tag: str) -> bool: + return len(parent_element.findall(f"./{child_tag}")) == 1 + + +def _is_empty_after_inner_el_removal( # noqa: PLR0911 + parent_el: _Element, +) -> bool: + # pylint: disable=too-many-return-statements + if is_any_clone(parent_el): + return True + if is_group(parent_el): + return len(get_inner_resources(parent_el)) == 1 + if is_tag(parent_el): + return _is_last_element(parent_el, const.TAG_OBJREF) + if parent_el.tag == const.TAG_RESOURCE_SET: + return _is_last_element(parent_el, const.TAG_RESOURCE_REF) + if is_set_constraint(parent_el): + return _is_last_element(parent_el, const.TAG_RESOURCE_SET) + if is_location_constraint(parent_el): + return _is_last_element(parent_el, const.TAG_RULE) + return False + + +def _get_remote_node_name_constraint_references( + element: _Element, +) -> Iterable[_Element]: + """ + Return all location constraints referencing remote or guest node name. + """ + if not is_resource(element): + return [] + + if is_guest_node(element): + return find_location_constraints_referencing_node_name( + element, get_node_name_from_guest(element) + ) + + remote_node_name = get_node_name_from_remote(element) + if remote_node_name is not None: + return find_location_constraints_referencing_node_name( + element, remote_node_name + ) + + return [] diff --git a/pcs/lib/cib/resource/agent.py b/pcs/lib/cib/resource/agent.py index 0560324c0..d03f4051c 100644 --- a/pcs/lib/cib/resource/agent.py +++ b/pcs/lib/cib/resource/agent.py @@ -80,13 +80,11 @@ def get_default_operations( # add necessary actions if they are missing defined_operation_names = frozenset(op.name for op in action_list) - for op_name in _NECESSARY_OPERATIONS: - if op_name not in defined_operation_names: - action_list.append( - ResourceAgentAction( - op_name, None, None, None, None, None, False, False - ) - ) + action_list.extend( + ResourceAgentAction(op_name, None, None, None, None, None, False, False) + for op_name in _NECESSARY_OPERATIONS + if op_name not in defined_operation_names + ) # transform actions to operation definitions return [action_to_operation_dto(action) for action in action_list] diff --git a/pcs/lib/cib/resource/bundle.py b/pcs/lib/cib/resource/bundle.py index 6905bf0fd..5918a049d 100644 --- a/pcs/lib/cib/resource/bundle.py +++ b/pcs/lib/cib/resource/bundle.py @@ -223,7 +223,7 @@ def validate_new( ) -def append_new( +def append_new( # noqa: PLR0913 parent_element, id_provider, bundle_id, @@ -235,6 +235,7 @@ def append_new( meta_attributes, ): # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments """ Create new bundle and add it to the CIB @@ -339,7 +340,7 @@ def _get_report_unsupported_container(bundle_el): ) -def validate_update( +def validate_update( # noqa: PLR0913 id_provider, bundle_el, container_options, @@ -351,6 +352,7 @@ def validate_update( force_options=False, ): # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments """ Validate modifying an existing bundle, return list of report items @@ -383,7 +385,7 @@ def validate_update( ) -def update( +def update( # noqa: PLR0913 id_provider, bundle_el, container_options, @@ -395,6 +397,7 @@ def update( meta_attributes, ): # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments """ Modify an existing bundle (does not touch encapsulated resources) diff --git a/pcs/lib/cib/resource/clone.py b/pcs/lib/cib/resource/clone.py index a1c006db9..3facebd1b 100644 --- a/pcs/lib/cib/resource/clone.py +++ b/pcs/lib/cib/resource/clone.py @@ -19,9 +19,13 @@ from lxml import etree from lxml.etree import _Element +from pcs.common import ( + const, +) from pcs.common.pacemaker import nvset from pcs.common.pacemaker.resource.clone import CibResourceCloneDto from pcs.common.reports import ReportItemList +from pcs.common.tools import Version from pcs.lib.cib import ( nvpair, nvpair_multi, @@ -29,6 +33,13 @@ ) from pcs.lib.cib.const import TAG_RESOURCE_CLONE as TAG_CLONE from pcs.lib.cib.const import TAG_RESOURCE_MASTER as TAG_MASTER +from pcs.lib.cib.nvpair_multi import ( + NVSET_META, + find_nvsets, + nvset_append_new, + nvset_update, +) +from pcs.lib.cib.resource.operations import get_resource_operations from pcs.lib.cib.tools import IdProvider from pcs.lib.pacemaker.values import ( is_true, @@ -37,6 +48,9 @@ ALL_TAGS = [TAG_CLONE, TAG_MASTER] +META_GLOBALLY_UNIQUE = "globally-unique" +META_PROMOTABLE = "promotable" + def is_clone(resource_el: _Element) -> bool: return resource_el.tag == TAG_CLONE @@ -182,6 +196,13 @@ def get_inner_resource(clone_el: _Element) -> _Element: return cast(List[_Element], clone_el.xpath("./primitive | ./group"))[0] +def get_inner_primitives(clone_el: _Element) -> list[_Element]: + """ + Also returns primitives inside cloned groups. + """ + return cast(List[_Element], clone_el.xpath(".//primitive")) + + def validate_clone_id(clone_id: str, id_provider: IdProvider) -> ReportItemList: """ Validate that clone_id is a valid xml id and it is unique in the cib. @@ -193,3 +214,33 @@ def validate_clone_id(clone_id: str, id_provider: IdProvider) -> ReportItemList: validate_id(clone_id, reporter=report_list) report_list.extend(id_provider.book_ids(clone_id)) return report_list + + +def convert_master_to_promotable( + id_provider: IdProvider, cib_validate_with: Version, master_el: _Element +) -> None: + master_el.tag = TAG_CLONE + meta_attrs = {META_PROMOTABLE: "true"} + meta_attrs_nvset_list = find_nvsets(master_el, NVSET_META) + if meta_attrs_nvset_list: + nvset_update(meta_attrs_nvset_list[0], id_provider, meta_attrs) + else: + nvset_append_new( + master_el, + id_provider, + cib_validate_with, + NVSET_META, + nvpair_dict=meta_attrs, + nvset_options={}, + ) + + clone_primitives = get_inner_primitives(master_el) + + for primitive_el in clone_primitives: + ops_monitor = get_resource_operations(primitive_el, names=["monitor"]) + for op_monitor in ops_monitor: + role = op_monitor.get("role", "") + if role == const.PCMK_ROLE_PROMOTED_LEGACY: + op_monitor.set("role", const.PCMK_ROLE_PROMOTED) + if role == const.PCMK_ROLE_UNPROMOTED_LEGACY: + op_monitor.set("role", const.PCMK_ROLE_UNPROMOTED) diff --git a/pcs/lib/cib/resource/common.py b/pcs/lib/cib/resource/common.py index 07370d6c9..eeb854f63 100644 --- a/pcs/lib/cib/resource/common.py +++ b/pcs/lib/cib/resource/common.py @@ -1,6 +1,5 @@ from typing import ( List, - Mapping, Optional, Set, Tuple, @@ -21,6 +20,9 @@ from .bundle import get_inner_resource as get_bundle_inner_resource from .bundle import is_bundle +from .clone import ( + get_inner_primitives as get_clone_inner_primitive_resources, +) from .clone import get_inner_resource as get_clone_inner_resource from .clone import is_any_clone from .group import get_inner_resources as get_group_inner_resources @@ -28,24 +30,7 @@ from .primitive import is_primitive -def are_meta_disabled(meta_attributes: Mapping[str, str]) -> bool: - return meta_attributes.get("target-role", "Started").lower() == "stopped" - - -def _can_be_evaluated_as_positive_num(value: str) -> bool: - string_wo_leading_zeros = str(value).lstrip("0") - return ( - bool(string_wo_leading_zeros) and string_wo_leading_zeros[0].isdigit() - ) - - -def is_clone_deactivated_by_meta(meta_attributes: Mapping[str, str]) -> bool: - return are_meta_disabled(meta_attributes) or any( - not _can_be_evaluated_as_positive_num(meta_attributes.get(key, "1")) - for key in ["clone-max", "clone-node-max"] - ) - - +# DEPRECATED, use get_element_by_id def find_one_resource( context_element: _Element, resource_id: str, @@ -67,6 +52,8 @@ def find_one_resource( return resource, report_list +# DEPRECATED, use get_elements_by_ids +# Issue: produces report with CIB tags when wrong element type was found def find_resources( context_element: _Element, resource_ids: StringCollection, @@ -85,8 +72,9 @@ def find_resources( resource_el_list = [] for res_id in resource_ids: searcher = ElementSearcher(resource_tags, res_id, context_element) - if searcher.element_found(): - resource_el_list.append(searcher.get_element()) + found_element = searcher.get_element() + if found_element is not None: + resource_el_list.append(found_element) else: report_list.extend(searcher.get_errors()) return resource_el_list, report_list @@ -102,7 +90,7 @@ def find_primitives(resource_el: _Element) -> List[_Element]: in_bundle = get_bundle_inner_resource(resource_el) return [in_bundle] if in_bundle is not None else [] if is_any_clone(resource_el): - resource_el = get_clone_inner_resource(resource_el) + return get_clone_inner_primitive_resources(resource_el) if is_group(resource_el): return get_group_inner_resources(resource_el) if is_primitive(resource_el): @@ -119,7 +107,7 @@ def get_all_inner_resources(resource_el: _Element) -> Set[_Element]: resource_el -- resource element to get its inner resources """ all_inner: Set[_Element] = set() - to_process = set([resource_el]) + to_process = {resource_el} while to_process: new_inner = get_inner_resources(to_process.pop()) to_process.update(set(new_inner) - all_inner) @@ -233,6 +221,18 @@ def disable(resource_el: _Element, id_provider: IdProvider) -> None: ) +def is_disabled(resource_el: _Element) -> bool: + """ + Is the resource disabled by its own meta? (Doesn't check parent resources.) + """ + return ( + nvpair.get_value( + nvpair.META_ATTRIBUTES_TAG, resource_el, "target-role", default="" + ).lower() + == "stopped" + ) + + def find_resources_to_manage(resource_el: _Element) -> List[_Element]: """ Get resources to set to managed for the specified resource to become managed @@ -314,8 +314,8 @@ def find_resources_to_unmanage(resource_el: _Element) -> List[_Element]: # a bundled primitive - the bundle - the bundle and the primitive # We need to unmanage implicit resources create by pacemaker and there is # no other way to do it than unmanage the bundle itself. - # Since it is not possible to unbundle a resource, the concers described - # at unclone don't apply here. However to prevent future bugs, in case + # Since it is not possible to unbundle a resource, the concerns described + # at unclone don't apply here. However, to prevent future bugs in case # unbundling becomes possible, we unmanage the primitive as well. # an empty bundle - the bundle - the bundle # There is nothing else to unmanage. @@ -357,39 +357,3 @@ def unmanage(resource_el: _Element, id_provider: IdProvider) -> None: {"is-managed": "false"}, id_provider, ) - - -def find_resources_to_delete(resource_el: _Element) -> List[_Element]: - """ - Get resources to delete, children and parents of the given resource if - necessary. - - If element is a primitive which is in a clone and you specify one of them, - you will get elements for both of them. If you specify group element which - is in a clone then will you get clone, group, and all primitive elements in - a group and etc. - - resource_el - resource element (bundle, clone, group, primitive) - """ - result = [resource_el] - # childrens of bundle, clone, group, clone-with-group - inner_resource_list = get_inner_resources(resource_el) - if inner_resource_list: - result.extend(inner_resource_list) - inner_resource = inner_resource_list[0] - if is_group(inner_resource): - result.extend(get_inner_resources(inner_resource)) - # parents of primitive if needed (group, clone) - parent_el = get_parent_resource(resource_el) - if parent_el is None or is_bundle(parent_el): - return result - if is_any_clone(parent_el): - result.insert(0, parent_el) - if is_group(parent_el): - group_inner_resources = get_group_inner_resources(parent_el) - if len(group_inner_resources) <= 1: - result = [parent_el] + group_inner_resources - clone_el = get_parent_resource(parent_el) - if clone_el is not None: - result.insert(0, clone_el) - return result diff --git a/pcs/lib/cib/resource/guest_node.py b/pcs/lib/cib/resource/guest_node.py index 496b96541..5a9f9078e 100644 --- a/pcs/lib/cib/resource/guest_node.py +++ b/pcs/lib/cib/resource/guest_node.py @@ -1,9 +1,15 @@ -from typing import Mapping +from typing import ( + Mapping, + cast, +) from lxml.etree import _Element from pcs.common import reports -from pcs.common.reports.item import ReportItem +from pcs.common.reports.item import ( + ReportItem, + ReportItemList, +) from pcs.common.types import StringCollection from pcs.lib import validate from pcs.lib.cib.node import PacemakerNode @@ -14,15 +20,150 @@ ) from pcs.lib.cib.tools import does_id_exist +OPTION_REMOTE_NODE = "remote-node" +_OPTION_REMOTE_ADDR = "remote-addr" +_OPTION_REMOTE_PORT = "remote-port" +_OPTION_REMOTE_CONN_TIMEOUT = "remote-connect-timeout" + + # TODO pcs currently does not care about multiple meta_attributes and here # we don't care as well GUEST_OPTIONS = [ - "remote-port", - "remote-addr", - "remote-connect-timeout", + _OPTION_REMOTE_ADDR, + _OPTION_REMOTE_PORT, + _OPTION_REMOTE_CONN_TIMEOUT, ] +def validate_updating_guest_attributes( + cib: _Element, + existing_nodes_names: list[str], + existing_nodes_addrs: list[str], + new_meta_attrs: Mapping[str, str], + existing_meta_attrs: Mapping[str, str], + force_flags: reports.types.ForceFlags, +) -> ReportItemList: + """ + Guest nodes have an implicit connection resource created by Pacemaker with + attributes remote-node and remode-addr that defaults to remote-node. + Updating these attributes doesn't make sense because Pacemaker Remote also + needs authkey, so changing the address to another host will not work as + expected. However it can still be forced (in case of a networking change) + and additional check for IDs in CIB is performed. + + TODO: needs to be consolidated with checks in resource create during its + overhaul + + existing_nodes_names -- list of existing guest and remote node names to + check for name conflicts + existing_nodes_addrs -- list of existing guest and remote node addresses to + check for name conflicts, since address is used if name is not defined + new_meta_attrs -- meta attributes that are being updated with their new + values + existing_meta_attrs -- currently defined meta attributes with their values + force_flags -- force flags + """ + + validator_list = [ + validate.ValueTimeInterval(_OPTION_REMOTE_CONN_TIMEOUT), + validate.ValuePortNumber(_OPTION_REMOTE_PORT), + ] + for validator in validator_list: + validator.empty_string_valid = True + report_list = validate.ValidatorAll(validator_list).validate(new_meta_attrs) + + # Validate remote-node collision with other CIB IDs + report_list.extend( + validate_conflicts( + cib, + existing_nodes_names, + existing_nodes_addrs, + new_meta_attrs.get(OPTION_REMOTE_NODE, ""), + new_meta_attrs, + ) + ) + + # Validating previously undefined meta attributes + new_meta_attrs_set = set(new_meta_attrs.keys()) + existing_meta_attrs_set = set(existing_meta_attrs.keys()) + + # Only addition of remote-node constitutes creating of a guest node, just + # adding remote-addr when remote-node is not defined is fine + added_guest_conn_keys = set(new_meta_attrs_set - existing_meta_attrs_set) + if OPTION_REMOTE_NODE in added_guest_conn_keys: + # Suggesting remove is not needed, this is only triggered when the + # attributes weren't defined previously + report_list.append( + ReportItem( + severity=reports.item.get_severity_from_flags( + reports.codes.FORCE, + force_flags, + ), + message=reports.messages.UseCommandNodeAddGuest(), + ) + ) + # Never return reports with contradictory guidance + return report_list + + # Validating previously defined meta attributes + updated_guest_conn_keys = existing_meta_attrs_set.intersection( + new_meta_attrs_set + ).intersection( + # These keys are crucial for defining the connection to the guest node + # and should only be changed by recreating the guest node in most cases + {OPTION_REMOTE_NODE, _OPTION_REMOTE_ADDR} + ) + if any( + new_meta_attrs[key] != existing_meta_attrs[key] + for key in updated_guest_conn_keys + ): + # If all new values are empty, this is a delete operation + if not all(new_meta_attrs[key] for key in updated_guest_conn_keys): + report_list.append( + ReportItem( + severity=reports.item.get_severity_from_flags( + reports.codes.FORCE, + force_flags, + ), + message=reports.messages.UseCommandNodeRemoveGuest(), + ) + ) + elif OPTION_REMOTE_NODE in existing_meta_attrs: + # Otherwise, this is an update, suggest readding resource but only + # if remote-node is defined - if it isn't, it's not a dangerous + # operation + report_list.append( + ReportItem( + severity=reports.item.get_severity_from_flags( + reports.codes.FORCE, + force_flags, + ), + message=reports.messages.UseCommandRemoveAndAddGuestNode(), + ) + ) + + # Special case - when remote-node is set up without node-addr, it is used as + # an address, so adding remote-addr counts as an address change too + if ( + _OPTION_REMOTE_ADDR not in existing_meta_attrs + and OPTION_REMOTE_NODE in existing_meta_attrs + and existing_meta_attrs[OPTION_REMOTE_NODE] + and _OPTION_REMOTE_ADDR in new_meta_attrs + and new_meta_attrs[_OPTION_REMOTE_ADDR] + ): + report_list.append( + ReportItem( + severity=reports.item.get_severity_from_flags( + reports.codes.FORCE, + force_flags, + ), + message=reports.messages.UseCommandRemoveAndAddGuestNode(), + ) + ) + + return report_list + + def validate_conflicts( tree: _Element, existing_nodes_names: StringCollection, @@ -34,33 +175,40 @@ def validate_conflicts( if ( does_id_exist(tree, node_name) or node_name in existing_nodes_names - or ("remote-addr" not in options and node_name in existing_nodes_addrs) + or ( + _OPTION_REMOTE_ADDR not in options + and node_name in existing_nodes_addrs + ) ): report_list.append( - ReportItem.error(reports.messages.IdAlreadyExists(node_name)) + ReportItem.error( + reports.messages.GuestNodeNameAlreadyExists(node_name) + ) ) if ( - "remote-addr" in options - and options["remote-addr"] in existing_nodes_addrs + _OPTION_REMOTE_ADDR in options + and options[_OPTION_REMOTE_ADDR] in existing_nodes_addrs ): report_list.append( ReportItem.error( - reports.messages.IdAlreadyExists(options["remote-addr"]) + reports.messages.NodeAddressesAlreadyExist( + [options[_OPTION_REMOTE_ADDR]] + ) ) ) return report_list def is_node_name_in_options(options): - return "remote-node" in options + return OPTION_REMOTE_NODE in options def get_guest_option_value(options, default=None): """ Commandline options: no options """ - return options.get("remote-node", default) + return options.get(OPTION_REMOTE_NODE, default) def validate_set_as_guest( @@ -68,8 +216,8 @@ def validate_set_as_guest( ): validator_list = [ validate.NamesIn(GUEST_OPTIONS, option_type="guest"), - validate.ValueTimeInterval("remote-connect-timeout"), - validate.ValuePortNumber("remote-port"), + validate.ValueTimeInterval(_OPTION_REMOTE_CONN_TIMEOUT), + validate.ValuePortNumber(_OPTION_REMOTE_PORT), ] return ( validate.ValidatorAll(validator_list).validate(options) @@ -88,7 +236,7 @@ def is_guest_node(resource_element): etree.Element resource_element is a search element """ - return has_meta_attribute(resource_element, "remote-node") + return has_meta_attribute(resource_element, OPTION_REMOTE_NODE) def validate_is_not_guest(resource_element): @@ -121,13 +269,13 @@ def set_as_guest( etree.Element resource_element """ - meta_options = {"remote-node": str(node)} + meta_options = {OPTION_REMOTE_NODE: str(node)} if addr: - meta_options["remote-addr"] = str(addr) + meta_options[_OPTION_REMOTE_ADDR] = str(addr) if port: - meta_options["remote-port"] = str(port) + meta_options[_OPTION_REMOTE_PORT] = str(port) if connect_timeout: - meta_options["remote-connect-timeout"] = str(connect_timeout) + meta_options[_OPTION_REMOTE_CONN_TIMEOUT] = str(connect_timeout) arrange_first_meta_attributes(resource_element, meta_options, id_provider) @@ -149,7 +297,7 @@ def unset_guest(resource_element): " or ".join( [ f'@name="{option}"' - for option in (GUEST_OPTIONS + ["remote-node"]) + for option in (GUEST_OPTIONS + [OPTION_REMOTE_NODE]) ] ) ) @@ -164,7 +312,7 @@ def get_node_name_from_options(meta_options, default=None): Return node_name from meta options. dict meta_options """ - return meta_options.get("remote-node", default) + return meta_options.get(OPTION_REMOTE_NODE, default) def get_node_name_from_resource(resource_element): @@ -173,7 +321,7 @@ def get_node_name_from_resource(resource_element): etree.Element resource_element """ - return get_meta_attribute_value(resource_element, "remote-node") + return get_meta_attribute_value(resource_element, OPTION_REMOTE_NODE) def find_node_list(tree): @@ -198,9 +346,9 @@ def find_node_list(tree): host = None name = None for nvpair in meta_attrs: - if nvpair.attrib.get("name", "") == "remote-addr": + if nvpair.attrib.get("name", "") == _OPTION_REMOTE_ADDR: host = nvpair.attrib["value"] - if nvpair.attrib.get("name", "") == "remote-node": + if nvpair.attrib.get("name", "") == OPTION_REMOTE_NODE: name = nvpair.attrib["value"] if host is None: host = name @@ -212,47 +360,51 @@ def find_node_list(tree): return node_list -def find_node_resources(resources_section, node_identifier): +def find_node_resources( + resources_section: _Element, node_identifier: str +) -> list[_Element]: """ - Return list of etree.Element primitives that are guest nodes. + Return list of primitive elements that are guest nodes. - etree.Element resources_section is a researched element - string node_identifier could be id of resource, node name or node address + resources_section -- searched element + node_identifier -- could be id of resource, node name or node address """ - resources = resources_section.xpath( - """ - .//primitive[ - ( - @id=$node_id - and + return cast( + list[_Element], + resources_section.xpath( + """ + .//primitive[ + ( + @id=$node_id + and + meta_attributes[ + nvpair[ + @name="remote-node" + and + string-length(@value) > 0 + ] + ] + ) + or meta_attributes[ nvpair[ @name="remote-node" and string-length(@value) > 0 ] - ] - ) - or - meta_attributes[ - nvpair[ - @name="remote-node" and - string-length(@value) > 0 - ] - and - nvpair[ - ( - @name="remote-addr" - or - @name="remote-node" - ) - and - @value=$node_id + nvpair[ + ( + @name="remote-addr" + or + @name="remote-node" + ) + and + @value=$node_id + ] ] ] - ] - """, - node_id=node_identifier, + """, + node_id=node_identifier, + ), ) - return resources diff --git a/pcs/lib/cib/resource/operations.py b/pcs/lib/cib/resource/operations.py index e44900cb9..a9b9ed382 100644 --- a/pcs/lib/cib/resource/operations.py +++ b/pcs/lib/cib/resource/operations.py @@ -5,6 +5,7 @@ List, Optional, Tuple, + cast, ) from lxml import etree @@ -327,6 +328,7 @@ def uniquify_operations_intervals( new_operations = [] for operation in operation_list: new_interval = get_unique_interval(operation.name, operation.interval) + new_operation = operation if new_interval != operation.interval: report_list.append( ReportItem.warning( @@ -337,8 +339,8 @@ def uniquify_operations_intervals( ) ) ) - operation = dt_replace(operation, interval=new_interval) - new_operations.append(operation) + new_operation = dt_replace(operation, interval=new_interval) + new_operations.append(new_operation) return report_list, new_operations @@ -406,11 +408,11 @@ def append_new_operation(operations_element, id_provider, options): IdProvider id_provider -- elements' ids generator dict options are attributes of operation """ - attribute_map = dict( - (key, value) + attribute_map = { + key: value for key, value in options.items() if key not in OPERATION_NVPAIR_ATTRIBUTES - ) + } if "id" in attribute_map: if does_id_exist(operations_element, attribute_map["id"]): raise LibraryError( @@ -434,11 +436,11 @@ def append_new_operation(operations_element, id_provider, options): "op", attribute_map, ) - nvpair_attribute_map = dict( - (key, value) + nvpair_attribute_map = { + key: value for key, value in options.items() if key in OPERATION_NVPAIR_ATTRIBUTES - ) + } if nvpair_attribute_map: append_new_instance_attributes( @@ -448,15 +450,17 @@ def append_new_operation(operations_element, id_provider, options): return op_element -def get_resource_operations(resource_el, names=None): +def get_resource_operations( + resource_el: _Element, names: Optional[StringCollection] = None +) -> list[_Element]: """ Get operations of a given resource, optionally filtered by name - etree resource_el -- resource element - iterable names -- return only operations of these names if specified + resource_el -- resource element + names -- return only operations of these names if specified """ return [ op_el - for op_el in resource_el.xpath("./operations/op") + for op_el in cast(list[_Element], resource_el.xpath("./operations/op")) if not names or op_el.attrib.get("name", "") in names ] diff --git a/pcs/lib/cib/resource/primitive.py b/pcs/lib/cib/resource/primitive.py index 851dd9b91..b401014f9 100644 --- a/pcs/lib/cib/resource/primitive.py +++ b/pcs/lib/cib/resource/primitive.py @@ -57,6 +57,16 @@ def is_primitive(resource_el: _Element) -> bool: return resource_el.tag == TAG +def resource_agent_name_from_primitive( + primitive_el: _Element, +) -> ResourceAgentName: + return ResourceAgentName( + standard=str(primitive_el.attrib["class"]), + provider=primitive_el.get("provider"), + type=str(primitive_el.attrib["type"]), + ) + + def primitive_element_to_dto( primitive_element: _Element, rule_eval: Optional[rule.RuleInEffectEval] = None, @@ -96,7 +106,7 @@ def primitive_element_to_dto( ) -def _find_primitives_by_agent( +def find_primitives_by_agent( resources_section: _Element, agent_name: ResourceAgentName ) -> List[_Element]: """ @@ -121,7 +131,7 @@ def _find_primitives_by_agent( ) -def create( +def create( # noqa: PLR0913 report_processor: reports.ReportProcessor, cmd_runner: CommandRunner, resources_section: _Element, @@ -141,6 +151,7 @@ def create( ): # pylint: disable=too-many-arguments # pylint: disable=too-many-locals + # pylint: disable=too-many-positional-arguments """ Prepare all parts of primitive resource and append it into cib. @@ -170,11 +181,10 @@ def create( if instance_attributes is None: instance_attributes = {} - filtered_raw_operation_list = [] - for op in raw_operation_list: - filtered_raw_operation_list.append( - {name: "" if value is None else value for name, value in op.items()} - ) + filtered_raw_operation_list = [ + {name: "" if value is None else value for name, value in op.items()} + for op in raw_operation_list + ] if does_id_exist(resources_section, resource_id): raise LibraryError( @@ -243,18 +253,20 @@ def create( ) -def append_new( +def append_new( # noqa: PLR0913 resources_section, id_provider, resource_id, standard, provider, agent_type, + *, instance_attributes=None, meta_attributes=None, operation_list=None, ): # pylint:disable=too-many-arguments + # pylint: disable=too-many-positional-arguments """ Append a new primitive element to the resources_section. @@ -306,7 +318,7 @@ def _validate_unique_instance_attributes( return [] report_list = [] - same_agent_resources = _find_primitives_by_agent( + same_agent_resources = find_primitives_by_agent( resources_section, resource_agent.name ) diff --git a/pcs/lib/cib/resource/relations.py b/pcs/lib/cib/resource/relations.py index 5a76e4500..e99ca372c 100644 --- a/pcs/lib/cib/resource/relations.py +++ b/pcs/lib/cib/resource/relations.py @@ -70,10 +70,11 @@ def stop(self) -> None: def add_member(self, member: "ResourceRelationNode") -> None: # pylint: disable=protected-access - if member._parent is not None: + if member._parent is not None: # noqa: SLF001 raise AssertionError( "object {} already has a parent set: {}".format( - repr(member), repr(member._parent) + repr(member), + repr(member._parent), # noqa: SLF001 ) ) # we don't want opposite relations (inner resource vs outer resource) @@ -83,18 +84,18 @@ def add_member(self, member: "ResourceRelationNode") -> None: self != member and member.obj.id not in parents and ( - member._opposite_id not in parents + member._opposite_id not in parents # noqa: SLF001 or len(member.obj.members) > 1 ) ): - member._parent = self + member._parent = self # noqa: SLF001 self._members.append(member) def _get_all_parents(self) -> List[str]: # pylint: disable=protected-access if self._parent is None: return [] - return self._parent._get_all_parents() + [self._parent.obj.id] + return self._parent._get_all_parents() + [self._parent.obj.id] # noqa: SLF001 class ResourceRelationTreeBuilder: diff --git a/pcs/lib/cib/resource/remote_node.py b/pcs/lib/cib/resource/remote_node.py index 0495ba9be..c58ef224d 100644 --- a/pcs/lib/cib/resource/remote_node.py +++ b/pcs/lib/cib/resource/remote_node.py @@ -2,6 +2,7 @@ Iterable, Mapping, Optional, + cast, ) from lxml.etree import _Element @@ -24,9 +25,7 @@ _IS_REMOTE_AGENT_XPATH_SNIPPET = """ @class="{0}" and @provider="{1}" and @type="{2}" -""".format( - AGENT_NAME.standard, AGENT_NAME.provider, AGENT_NAME.type -) +""".format(AGENT_NAME.standard, AGENT_NAME.provider, AGENT_NAME.type) _HAS_SERVER_XPATH_SNIPPET = """ instance_attributes/nvpair[ @@ -70,16 +69,20 @@ def find_node_list(tree): return node_list -def find_node_resources(resources_section, node_identifier): +def find_node_resources( + resources_section: _Element, node_identifier: str +) -> list[_Element]: """ Return list of resource elements that match to node_identifier - etree.Element resources_section is a search element - string node_identifier could be id of the resource or its instance attribute + resources_section -- search element + node_identifier -- could be id of the resource or its instance attribute "server" """ - return resources_section.xpath( - f""" + return cast( + list[_Element], + resources_section.xpath( + f""" .//primitive[ {_IS_REMOTE_AGENT_XPATH_SNIPPET} and ( @id=$identifier @@ -92,7 +95,8 @@ def find_node_resources(resources_section, node_identifier): ) ] """, - identifier=node_identifier, + identifier=node_identifier, + ), ) @@ -141,7 +145,9 @@ def validate_host_not_conflicts( host = instance_attributes.get("server", node_name) if host in existing_nodes_addrs: return [ - reports.ReportItem.error(reports.messages.IdAlreadyExists(host)) + reports.ReportItem.error( + reports.messages.NodeAddressesAlreadyExist([host]) + ) ] return [] @@ -197,7 +203,7 @@ def _prepare_instance_attributes( return enriched_instance_attributes -def create( +def create( # noqa: PLR0913 report_processor: reports.ReportProcessor, cmd_runner: CommandRunner, resource_agent_facade: ResourceAgentFacade, @@ -213,6 +219,7 @@ def create( use_default_operations: bool = True, ): # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments """ Prepare all parts of remote resource and append it into the cib. diff --git a/pcs/lib/cib/resource/stonith.py b/pcs/lib/cib/resource/stonith.py index 3f17c460d..c2f4a5ab8 100644 --- a/pcs/lib/cib/resource/stonith.py +++ b/pcs/lib/cib/resource/stonith.py @@ -1,9 +1,7 @@ import re from typing import ( - Dict, - List, + Mapping, Optional, - Tuple, cast, ) @@ -36,19 +34,7 @@ ) -# TODO replace by the new finding function -def is_stonith_resource(resources_el, name): - return ( - len( - resources_el.xpath( - "primitive[@id=$id and @class='stonith']", id=name - ) - ) - > 0 - ) - - -def is_stonith(resource_el: _Element): +def is_stonith(resource_el: _Element) -> bool: return ( resource_el.tag == TAG_RESOURCE_PRIMITIVE and resource_el.get("class") == "stonith" @@ -69,15 +55,39 @@ def is_stonith_enabled(crm_config_el: _Element) -> bool: return stonith_enabled +def get_all_resources(resources_el: _Element) -> list[_Element]: + """ + Return all stonith resources + """ + return cast( + list[_Element], resources_el.xpath("//primitive[@class='stonith']") + ) + + +def get_all_node_isolating_resources(resources_el: _Element) -> list[_Element]: + """ + Return all stonith resources which actually do fencing on their own + """ + return [ + res_el + for res_el in get_all_resources(resources_el) + if res_el.get("type") + not in { + "fence_heuristics_ping", + "fence_kdump", + "fence_sbd", + "fence_watchdog", + } + ] + + def get_misconfigured_resources( resources_el: _Element, -) -> Tuple[List[_Element], List[_Element], List[_Element]]: +) -> tuple[list[_Element], list[_Element], list[_Element]]: """ Return stonith: all, 'action' option set, 'method' option set to 'cycle' """ - stonith_all = cast( - List[_Element], resources_el.xpath("//primitive[@class='stonith']") - ) + stonith_all = get_all_resources(resources_el) stonith_with_action = [] stonith_with_method_cycle = [] for stonith in stonith_all: @@ -85,7 +95,8 @@ def get_misconfigured_resources( if nvpair.get("name") == "action" and nvpair.get("value"): stonith_with_action.append(stonith) if ( - nvpair.get("name") == "method" + stonith.get("type") != "fence_sbd" + and nvpair.get("name") == "method" and nvpair.get("value") == "cycle" ): stonith_with_method_cycle.append(stonith) @@ -98,7 +109,7 @@ def get_misconfigured_resources( def validate_stonith_restartless_update( cib: _Element, stonith_id: str, -) -> Tuple[Optional[_Element], ReportItemList]: +) -> tuple[Optional[_Element], ReportItemList]: """ Validate that stonith device exists and its type is supported for restartless update of scsi devices and has defined option 'devices'. @@ -143,7 +154,7 @@ def validate_stonith_restartless_update( def get_node_key_map_for_mpath( stonith_el: _Element, node_labels: StringIterable -) -> Dict[str, str]: +) -> dict[str, str]: def library_error( host_map: Optional[str], missing_nodes: StringIterable ) -> LibraryError: @@ -190,8 +201,8 @@ def library_error( def _get_digest( attr: str, - attr_to_type_map: Dict[str, str], - calculated_digests: Dict[str, Optional[str]], + attr_to_type_map: Mapping[str, str], + calculated_digests: Mapping[str, Optional[str]], ) -> str: """ Return digest of right type for the specified attribute. If missing, raise @@ -218,7 +229,7 @@ def _get_digest( return digest -def _get_transient_instance_attributes(cib: _Element) -> List[_Element]: +def _get_transient_instance_attributes(cib: _Element) -> list[_Element]: """ Return list of instance_attributes elements which could contain digest attributes. @@ -226,7 +237,7 @@ def _get_transient_instance_attributes(cib: _Element) -> List[_Element]: cib -- CIB root element """ return cast( - List[_Element], + list[_Element], cib.xpath( "./status/node_state/transient_attributes/instance_attributes" ), @@ -239,7 +250,7 @@ def _get_lrm_rsc_op_elements( node_name: str, op_name: str, interval: Optional[str] = None, -) -> List[_Element]: +) -> list[_Element]: """ Get a lrm_rsc_op element from cib status. @@ -249,15 +260,13 @@ def _get_lrm_rsc_op_elements( interval -- operation interval using for monitor operation selection """ return cast( - List[_Element], + list[_Element], cib.xpath( """ ./status/node_state[@uname=$node_name] /lrm/lrm_resources/lrm_resource[@id=$resource_id] /lrm_rsc_op[@operation=$op_name{interval}] - """.format( - interval=" and @interval=$interval" if interval else "" - ), + """.format(interval=" and @interval=$interval" if interval else ""), node_name=node_name, resource_id=resource_id, op_name=op_name, @@ -268,9 +277,9 @@ def _get_lrm_rsc_op_elements( def _get_monitor_attrs( resource_el: _Element, -) -> List[Dict[str, Optional[str]]]: +) -> list[dict[str, Optional[str]]]: """ - Get list of interval/timeout attributes of all monitor oparations of + Get list of interval/timeout attributes of all monitor operations of the resource which is being updated. Only interval and timeout attributes are needed for digests @@ -284,7 +293,7 @@ def _get_monitor_attrs( from the resource definition and lrm_rsc_op elements from the cluster status, it will be found later. """ - monitor_attrs_list: List[Dict[str, Optional[str]]] = [] + monitor_attrs_list: list[dict[str, Optional[str]]] = [] for operation_el in operations.get_resource_operations( resource_el, names=["monitor"] ): @@ -309,8 +318,8 @@ def _get_monitor_attrs( def _update_digest_attrs_in_lrm_rsc_op( - lrm_rsc_op: _Element, calculated_digests: Dict[str, Optional[str]] -): + lrm_rsc_op: _Element, calculated_digests: Mapping[str, Optional[str]] +) -> None: """ Update digest attributes in lrm_rsc_op elements. If there are missing digests values from pacemaker or missing digests attributes in lrm_rsc_op @@ -358,6 +367,7 @@ def _get_transient_digest_value( """ new_comma_values_list = [] for comma_value in old_value.split(","): + new_comma_value = comma_value if comma_value: try: _id, _type, _ = comma_value.split(":") @@ -370,8 +380,8 @@ def _get_transient_digest_value( ) ) from e if _id == stonith_id and _type == stonith_type: - comma_value = ":".join([stonith_id, stonith_type, digest]) - new_comma_values_list.append(comma_value) + new_comma_value = ":".join([stonith_id, stonith_type, digest]) + new_comma_values_list.append(new_comma_value) return ",".join(new_comma_values_list) @@ -379,7 +389,7 @@ def _update_digest_attrs_in_transient_instance_attributes( nvset_el: _Element, stonith_id: str, stonith_type: str, - calculated_digests: Dict[str, Optional[str]], + calculated_digests: Mapping[str, Optional[str]], ) -> None: """ Update digests attributes in transient instance attributes element. @@ -392,7 +402,7 @@ def _update_digest_attrs_in_transient_instance_attributes( """ for attr in TRANSIENT_DIGEST_ATTRS: nvpair_list = cast( - List[_Element], + list[_Element], nvset_el.xpath("./nvpair[@name=$name]", name=attr), ) if not nvpair_list: diff --git a/pcs/lib/cib/rule/cib_to_str.py b/pcs/lib/cib/rule/cib_to_str.py index 29b67a8a9..aca890414 100644 --- a/pcs/lib/cib/rule/cib_to_str.py +++ b/pcs/lib/cib/rule/cib_to_str.py @@ -51,10 +51,9 @@ def _date_to_str(date: str) -> str: # remove spaces around separators result = re.sub(RuleToStr._date_separators_re, r"\1", date) # if there are any spaces left, replace the first one with T - result = re.sub(r"\s+", "T", result, count=1) # keep all other spaces in place # the date wouldn't be valid, but there is nothing more we can do - return result + return re.sub(r"\s+", "T", result, count=1) def _rule_to_str(self, rule_el: _Element) -> str: # "and" is a documented pacemaker default diff --git a/pcs/lib/cib/rule/compat_pyparsing.py b/pcs/lib/cib/rule/compat_pyparsing.py index 3947fdb9e..91c97a82f 100644 --- a/pcs/lib/cib/rule/compat_pyparsing.py +++ b/pcs/lib/cib/rule/compat_pyparsing.py @@ -27,6 +27,9 @@ nums, ) +SUPPRESS_LEFT_PARENTHESIS = Suppress("(") +SUPPRESS_RIGHT_PARENTHESIS = Suppress(")") + if pyparsing.__version__.startswith("3."): from pyparsing import ( # pylint: disable=no-name-in-module OpAssoc, @@ -73,8 +76,8 @@ def set_results_name( def infix_notation( # type: ignore base_expr: pyparsing.ParserElement, op_list: list[Any], - lpar: Union[str, pyparsing.ParserElement] = Suppress("("), - rpar: Union[str, pyparsing.ParserElement] = Suppress(")"), + lpar: Union[str, pyparsing.ParserElement] = SUPPRESS_LEFT_PARENTHESIS, + rpar: Union[str, pyparsing.ParserElement] = SUPPRESS_RIGHT_PARENTHESIS, ) -> pyparsing.ParserElement: # pylint: disable=too-many-function-args return pyparsing.infixNotation(base_expr, op_list, lpar, rpar) # type: ignore diff --git a/pcs/lib/cib/rule/expression_part.py b/pcs/lib/cib/rule/expression_part.py index 843310dc3..33d3709bb 100644 --- a/pcs/lib/cib/rule/expression_part.py +++ b/pcs/lib/cib/rule/expression_part.py @@ -7,7 +7,6 @@ NewType, Optional, Sequence, - Tuple, ) BoolOperator = NewType("BoolOperator", str) @@ -65,9 +64,9 @@ class DateInRangeExpr(RuleExprPart): Represents a 'date in range' expression """ - date_start: str + date_start: Optional[str] date_end: Optional[str] - duration_parts: Optional[Sequence[Tuple[str, str]]] + duration_parts: Optional[Sequence[tuple[str, str]]] @dataclass(frozen=True) @@ -76,7 +75,7 @@ class DatespecExpr(RuleExprPart): Represents a date-spec expression """ - date_parts: Sequence[Tuple[str, str]] + date_parts: Sequence[tuple[str, str]] @dataclass(frozen=True) diff --git a/pcs/lib/cib/rule/in_effect.py b/pcs/lib/cib/rule/in_effect.py index c70877f8e..4e2f827fc 100644 --- a/pcs/lib/cib/rule/in_effect.py +++ b/pcs/lib/cib/rule/in_effect.py @@ -32,6 +32,7 @@ class RuleInEffectEvalDummy(RuleInEffectEval): """ def get_rule_status(self, rule_id: str) -> CibRuleInEffectStatus: + del rule_id return CibRuleInEffectStatus.UNKNOWN diff --git a/pcs/lib/cib/rule/parser.py b/pcs/lib/cib/rule/parser.py index 212ba4a16..5e61019bb 100644 --- a/pcs/lib/cib/rule/parser.py +++ b/pcs/lib/cib/rule/parser.py @@ -186,7 +186,7 @@ def __build_date_inrange_expr( def __build_datespec_expr(parse_result: pyparsing.ParseResults) -> RuleExprPart: # Those attrs are defined by setResultsName in datespec_expr grammar rule return DatespecExpr( - parse_result.datespec.as_list() if parse_result.datespec else None + parse_result.datespec.as_list() if parse_result.datespec else () ) diff --git a/pcs/lib/cib/rule/validator.py b/pcs/lib/cib/rule/validator.py index 3edf8a113..2816170fa 100644 --- a/pcs/lib/cib/rule/validator.py +++ b/pcs/lib/cib/rule/validator.py @@ -3,7 +3,6 @@ from typing import ( List, Set, - cast, ) from dateutil import parser as dateutil_parser @@ -57,7 +56,7 @@ def get_reports(self) -> reports.ReportItemList: ) return report_list - def _call_validate(self, expr: RuleExprPart) -> reports.ReportItemList: + def _call_validate(self, expr: RuleExprPart) -> reports.ReportItemList: # noqa: PLR0911 # pylint: disable=too-many-return-statements if isinstance(expr, BoolExpr): return self._validate_bool_expr(expr) @@ -131,17 +130,19 @@ def _validate_date_inrange_expr( ) ) if ( - start_date is not None + # start and end dates have been specified + expr.date_start is not None + and expr.date_end is not None + # start and end dates are valid dates + and start_date is not None and end_date is not None + # start happens later than end and start_date >= end_date ): report_list.append( reports.item.ReportItem.error( message=reports.messages.RuleExpressionSinceGreaterThanUntil( - expr.date_start, - # If end_date is not None, then expr.date_end is not - # None, but mypy does not see it. - cast(str, expr.date_end), + expr.date_start, expr.date_end ), ) ) diff --git a/pcs/lib/cib/sections.py b/pcs/lib/cib/sections.py index dc59e3480..32bbe02e9 100644 --- a/pcs/lib/cib/sections.py +++ b/pcs/lib/cib/sections.py @@ -3,6 +3,8 @@ for getting existing sections from the cib (lxml) tree. """ +from lxml.etree import _Element + from pcs.common import reports from pcs.common.reports.item import ReportItem from pcs.lib.errors import LibraryError @@ -39,7 +41,7 @@ ] -def get(tree, section_name): +def get(tree: _Element, section_name: str) -> _Element: """ Return the element which represents section 'section_name' in the tree. @@ -68,7 +70,7 @@ def get(tree, section_name): raise AssertionError(f"Unknown cib section '{section_name}'") -def exists(tree, section_name): +def exists(tree: _Element, section_name: str) -> bool: if section_name not in __MANDATORY_SECTIONS + __OPTIONAL_SECTIONS: raise AssertionError(f"Unknown cib section '{section_name}'") return tree.find(f".//{section_name}") is not None diff --git a/pcs/lib/cib/tag.py b/pcs/lib/cib/tag.py index cab36b3b6..46ef4e514 100644 --- a/pcs/lib/cib/tag.py +++ b/pcs/lib/cib/tag.py @@ -15,6 +15,7 @@ from lxml.etree import _Element from pcs.common import reports +from pcs.common.pacemaker.tag import CibTagDto from pcs.common.reports import ( ReportItem, ReportItemList, @@ -85,7 +86,7 @@ def _validate_add_remove_duplicate_reference_ids( add_or_not_remove -- flag for add/remove action """ duplicate_ids_list = [ - id for id, count in Counter(idref_list).items() if count > 1 + id_ for id_, count in Counter(idref_list).items() if count > 1 ] if duplicate_ids_list: return [ @@ -267,7 +268,7 @@ def validate( return ( self._validate_tag_exists(tags_section) + self._validate_ids_for_update_are_specified() - + self._valdiate_no_common_add_remove_ids() + + self._validate_no_common_add_remove_ids() + self._validate_ids_can_be_added_or_moved(resources_section) + self._validate_ids_can_be_removed() + self._validate_adjacent_id() @@ -309,7 +310,7 @@ def _validate_ids_for_update_are_specified(self) -> ReportItemList: ) return report_list - def _valdiate_no_common_add_remove_ids(self) -> ReportItemList: + def _validate_no_common_add_remove_ids(self) -> ReportItemList: """ Validate that we do not remove ids currently being added. """ @@ -534,14 +535,15 @@ def find_tag_elements_by_ids( list in case of errors. tags_section -- element tags - tag_id_list -- list of tag indentifiers + tag_id_list -- list of tag identifiers """ element_list = [] report_list: ReportItemList = [] for tag_id in tag_id_list: searcher = ElementSearcher(TAG_TAG, tag_id, tags_section) - if searcher.element_found(): - element_list.append(searcher.get_element()) + found_element = searcher.get_element() + if found_element is not None: + element_list.append(found_element) else: report_list.extend(searcher.get_errors()) @@ -658,6 +660,16 @@ def tag_element_to_dict( } +def tag_element_to_dto(tag_element: _Element) -> CibTagDto: + return CibTagDto( + str(tag_element.attrib["id"]), + [ + str(obj_ref.attrib["id"]) + for obj_ref in tag_element.findall(TAG_OBJREF) + ], + ) + + def expand_tag( some_or_tag_el: _Element, only_expand_types: Optional[StringCollection] = None, @@ -684,8 +696,9 @@ def expand_tag( searcher = ElementSearcher( only_expand_types, element_id, conf_section ) - if searcher.element_found(): - expanded_elements.append(searcher.get_element()) + found_element = searcher.get_element() + if found_element is not None: + expanded_elements.append(found_element) else: expanded_elements.extend( get_configuration_elements_by_id(conf_section, element_id) diff --git a/pcs/lib/cib/tools.py b/pcs/lib/cib/tools.py index 2c1227378..f45c1eee7 100644 --- a/pcs/lib/cib/tools.py +++ b/pcs/lib/cib/tools.py @@ -1,9 +1,8 @@ +import contextlib import re from typing import ( - List, - Pattern, - Set, - Tuple, + Optional, + Union, cast, ) @@ -23,7 +22,7 @@ ReportItemList, ) from pcs.common.tools import Version -from pcs.common.types import StringIterable +from pcs.common.types import StringCollection, StringIterable from pcs.lib.cib import sections from pcs.lib.errors import LibraryError from pcs.lib.pacemaker.values import ( @@ -60,13 +59,13 @@ def __init__(self, cib_element: _Element): cib_element -- any element of the xml to check against """ self._cib = get_root(cib_element) - self._booked_ids: Set[str] = set() + self._booked_ids: set[str] = set() def allocate_id(self, proposed_id: str) -> str: """ Generate a new unique id based on the proposal and keep track of it - string proposed_id -- requested id + proposed_id -- requested id """ final_id = find_unique_id(self._cib, proposed_id, self._booked_ids) self._booked_ids.add(final_id) @@ -109,41 +108,48 @@ class ElementSearcher: """ def __init__( - self, tags, element_id, context_element, element_type_desc=None + self, + tags: Union[str, StringIterable], + element_id: str, + context_element: _Element, + element_type_desc: Union[None, str, StringIterable] = None, ): """ - string|iterable tags -- a tag (string) or tags (iterable) to look for - string element_id -- an id to look for - etree.Element context_element -- an element to look in - string|iterable element_type_desc -- element types for reports, tags - if not specified + tags -- a tag (string) or tags (iterable) to look for + element_id -- an id to look for + context_element -- an element to look in + element_type_desc -- element types for reports, tags if not specified """ self._executed = False - self._element = None + self._element: Optional[_Element] = None self._element_id = element_id self._context_element = context_element self._tag_list = [tags] if isinstance(tags, str) else tags self._expected_types = self._prepare_expected_types(element_type_desc) - self._book_errors = None + self._book_errors: Optional[ReportItemList] = None - def _prepare_expected_types(self, element_type_desc): + def _prepare_expected_types( + self, element_type_desc: Union[None, str, StringIterable] + ) -> list[str]: if element_type_desc is None: - return self._tag_list + return list(self._tag_list) if isinstance(element_type_desc, str): return [element_type_desc] - return element_type_desc + return list(element_type_desc) - def element_found(self): + def element_found(self) -> bool: if not self._executed: self._execute() return self._element is not None - def get_element(self): + def get_element(self) -> Optional[_Element]: if not self._executed: self._execute() return self._element - def validate_book_id(self, id_provider, id_description="id"): + def validate_book_id( + self, id_provider: IdProvider, id_description: str = "id" + ) -> bool: """ Book element_id in the id_provider, return True if success """ @@ -157,7 +163,7 @@ def validate_book_id(self, id_provider, id_description="id"): self._book_errors += id_provider.book_ids(self._element_id) return len(self._book_errors) < 1 - def get_errors(self): + def get_errors(self) -> ReportItemList: """ Report why the element has not been found or booking its id failed """ @@ -207,13 +213,16 @@ def get_errors(self): ] return self._book_errors - def _execute(self): + def _execute(self) -> None: self._executed = True for tag in self._tag_list: - element_list = self._context_element.xpath( - ".//*[local-name()=$tag_name and @id=$element_id]", - tag_name=tag, - element_id=self._element_id, + element_list = cast( + list[_Element], + self._context_element.xpath( + ".//*[local-name()=$tag_name and @id=$element_id]", + tag_name=tag, + element_id=self._element_id, + ), ) if element_list: self._element = element_list[0] @@ -222,7 +231,7 @@ def _execute(self): def get_configuration_elements_by_id( tree: _Element, check_id: str -) -> List[_Element]: +) -> list[_Element]: """ Return any configuration elements (not in status section of cib) with value of attribute id specified as 'check_id'; skip any and all elements having id @@ -240,7 +249,7 @@ def get_configuration_elements_by_id( # attribute of the explicit resource. So the value of nvpair named # "remote-node" is considered to be id return cast( - List[_Element], + list[_Element], get_root(tree).xpath( """ ( @@ -298,13 +307,12 @@ def get_element_by_id(cib: _Element, element_id: str) -> _Element: def get_elements_by_ids( cib: _Element, element_ids: StringIterable -) -> Tuple[List[_Element], List[str]]: +) -> tuple[list[_Element], list[str]]: """ - Returns a list of elements from CIB with the given IDs and a list of IDs - that weren't found + Return elements from CIB with the given IDs and IDs that weren't found cib -- the whole cib - element_ids -- iterable with element IDs to look for + element_ids -- element IDs to look for """ found_element_list = [] id_not_found_list = [] @@ -317,9 +325,10 @@ def get_elements_by_ids( # DEPRECATED, use IdProvider instead -def does_id_exist(tree, check_id): +def does_id_exist(tree: _Element, check_id: str) -> bool: """ Checks to see if id exists in the xml dom passed + tree cib -- etree node check_id -- id to check """ @@ -327,9 +336,9 @@ def does_id_exist(tree, check_id): # DEPRECATED, use IdProvider instead -def validate_id_does_not_exist(tree, _id): +def validate_id_does_not_exist(tree: _Element, _id: str) -> None: """ - tree cib etree node + Raise LibraryError if specified id exists in specified dom tree """ if does_id_exist(tree, _id): raise LibraryError( @@ -338,13 +347,18 @@ def validate_id_does_not_exist(tree, _id): # DEPRECATED, use IdProvider instead -def find_unique_id(tree, check_id, reserved_ids=None): +def find_unique_id( + tree: _Element, + check_id: str, + reserved_ids: Optional[StringCollection] = None, +) -> str: """ - Returns check_id if it doesn't exist in the dom, otherwise it adds - an integer to the end of the id and increments it until a unique id is found - etree tree -- cib etree node - string check_id -- id to check - iterable reserved_ids -- ids to think about as already used + Return check_id if it doesn't exist in the dom, otherwise add an integer to + the end of the id and increment it until a unique id is found + + tree -- cib etree node + check_id -- id to check + reserved_ids -- ids to think about as already used """ if not reserved_ids: reserved_ids = set() @@ -358,19 +372,23 @@ def find_unique_id(tree, check_id, reserved_ids=None): # DEPRECATED, use ElementSearcher instead def find_element_by_tag_and_id( - tag, context_element, element_id, none_if_id_unused=False, id_types=None -): + tag: Union[str, StringIterable], + context_element: _Element, + element_id: str, + none_if_id_unused: bool = False, + id_types: Optional[StringIterable] = None, +) -> Optional[_Element]: """ Return element with given tag and element_id under context_element. When element does not exists raises LibraryError or return None if specified in none_if_id_unused. - etree.Element(Tree) context_element is part of tree for element scan - string|list tag is expected tag (or list of tags) of search element - string element_id is id of search element - bool none_if_id_unused if the element is not found then return None if True + tag -- expected tag (or list of tags) of search element + context_element -- part of tree for element scan + element_id -- id of search element + none_if_id_unused -- if the element is not found then return None if True or raise a LibraryError if False - list id_types optional list of descriptions for id / expected types of id + id_types -- optional list of descriptions for id / expected types of id """ searcher = ElementSearcher( tag, element_id, context_element, element_type_desc=id_types @@ -401,30 +419,33 @@ def create_subelement_id( # DEPRECATED # use ElementSearcher, IdProvider or pcs.lib.validate.ValueId instead -def check_new_id_applicable(tree, description, _id): +def check_new_id_applicable(tree: _Element, description: str, _id: str) -> None: validate_id(_id, description) validate_id_does_not_exist(tree, _id) -def get_configuration(tree): +def get_configuration(tree: _Element) -> _Element: """ Return 'configuration' element from tree, raise LibraryError if missing + tree cib etree node """ return sections.get(tree, sections.CONFIGURATION) -def get_acls(tree): +def get_acls(tree: _Element) -> _Element: """ Return 'acls' element from tree, create a new one if missing + tree cib etree node """ return sections.get(tree, sections.ACLS) -def get_alerts(tree): +def get_alerts(tree: _Element) -> _Element: """ Return 'alerts' element from tree, create a new one if missing + tree -- cib etree node """ return sections.get(tree, sections.ALERTS) @@ -433,6 +454,7 @@ def get_alerts(tree): def get_constraints(tree: _Element) -> _Element: """ Return 'constraint' element from tree + tree cib etree node """ return sections.get(tree, sections.CONSTRAINTS) @@ -447,17 +469,19 @@ def get_crm_config(tree: _Element) -> _Element: return sections.get(tree, sections.CRM_CONFIG) -def get_fencing_topology(tree): +def get_fencing_topology(tree: _Element) -> _Element: """ Return the 'fencing-topology' element from the tree + tree -- cib etree node """ return sections.get(tree, sections.FENCING_TOPOLOGY) -def get_nodes(tree): +def get_nodes(tree: _Element) -> _Element: """ Return 'nodes' element from the tree + tree cib etree node """ return sections.get(tree, sections.NODES) @@ -472,9 +496,10 @@ def get_resources(tree: _Element) -> _Element: return sections.get(tree, sections.RESOURCES) -def get_status(tree): +def get_status(tree: _Element) -> _Element: """ Return the 'status' element from the tree + tree -- cib etree node """ return get_sub_element(tree, "status") @@ -483,13 +508,14 @@ def get_status(tree): def get_tags(tree: _Element) -> _Element: """ Return 'tags' element from tree, create a new one if missing + tree -- cib etree node """ return sections.get(tree, sections.TAGS) def _get_cib_version( - cib: _ElementTree, attribute: str, regexp: Pattern + cib: _ElementTree, attribute: str, regexp: re.Pattern ) -> Version: version = cib.getroot().get(attribute) if version is None: @@ -559,25 +585,6 @@ def _get_configuration(element: _Element) -> _Element: return get_configuration(get_root(element)) -def _find_elements_without_id_referencing_id( - element: _Element, - referenced_id: str, -) -> list[_Element]: - """ - Find elements which are referencing specified id (resource or tag). - - element -- any element within CIB tree - referenced_id -- id which references should be found - """ - return cast( - list[_Element], - _get_configuration(element).xpath( - _ELEMENTS_WITH_IDREF_WITHOUT_ID_XPATH, - referenced_id=referenced_id, - ), - ) - - def find_elements_referencing_id( element: _Element, referenced_id: str, @@ -625,14 +632,68 @@ def find_elements_referencing_id( ) +def find_location_constraints_referencing_node_name( + element: _Element, node_name: str +) -> list[_Element]: + """ + Find location constraints which are referencing specified node name. + + element -- any element within CIB tree + node_name -- name of the node which references should be found + """ + return cast( + list[_Element], + _get_configuration(element).xpath( + "./constraints/rsc_location[@node=$node_name]", node_name=node_name + ), + ) + + def remove_element_by_id(cib: _Element, element_id: str) -> None: """ Remove element with specified id from cib element. """ - for ref_el in _find_elements_without_id_referencing_id(cib, element_id): - remove_one_element(ref_el) - - try: + with contextlib.suppress(ElementNotFound): remove_one_element(get_element_by_id(cib, element_id)) - except ElementNotFound: - pass + + +def multivalue_attr_contains_value( + element: _Element, attr_name: str, value: str +) -> bool: + """ + Return whether attribute, that can contain multiple comma separated values, + contains specified value + + element -- any element + attribute_name -- name of the multivalue attribute + value -- value that should be present in the attribute + """ + return value in str(element.attrib[attr_name]).split(",") + + +def multivalue_attr_has_any_values(element: _Element, attr_name: str) -> bool: + """ + Return whether attribute, that can contain multiple comma separated values, + contains any value + + element -- any element + attribute_name -- name of the multivalue attribute + """ + return element.attrib[attr_name] != "" + + +def multivalue_attr_delete_value( + element: _Element, attr_name: str, value: str +) -> None: + """ + Remove value from attribute, that can contain multiple comma separated + values. + + element -- any element + attribute_name -- name of the multivalue attribute + value -- value to remove + """ + new_attribute_value = [ + val for val in str(element.attrib[attr_name]).split(",") if val != value + ] + element.set(attr_name, ",".join(new_attribute_value)) diff --git a/pcs/lib/cluster_property.py b/pcs/lib/cluster_property.py index 24fb44225..76a562eef 100644 --- a/pcs/lib/cluster_property.py +++ b/pcs/lib/cluster_property.py @@ -54,19 +54,18 @@ def _validate_stonith_watchdog_timeout_property( validate.ValuePair(original_value, value), force ) ) - else: - if value not in ["", "0"]: - report_list.append( - reports.ReportItem.error( - reports.messages.StonithWatchdogTimeoutCannotBeSet( - reports.const.SBD_NOT_SET_UP - ), - ) + elif value not in ["", "0"]: + report_list.append( + reports.ReportItem.error( + reports.messages.StonithWatchdogTimeoutCannotBeSet( + reports.const.SBD_NOT_SET_UP + ), ) + ) return report_list -def validate_set_cluster_properties( +def validate_set_cluster_properties( # noqa: PLR0912 runner: CommandRunner, cluster_property_facade_list: Iterable[ResourceAgentFacade], properties_set_id: str, @@ -137,7 +136,7 @@ def validate_set_cluster_properties( validators: list[validate.ValidatorInterface] = [] for property_name in to_be_set_properties: if property_name not in possible_properties_dict: - # unknow properties are reported by NamesIn validator + # unknown properties are reported by NamesIn validator continue property_metadata = possible_properties_dict[property_name] if property_metadata.type == "boolean": diff --git a/pcs/lib/commands/acl.py b/pcs/lib/commands/acl.py index 88c685e13..d992acbe9 100644 --- a/pcs/lib/commands/acl.py +++ b/pcs/lib/commands/acl.py @@ -1,7 +1,16 @@ from contextlib import contextmanager +from typing import TYPE_CHECKING from pcs.lib.cib import acl -from pcs.lib.cib.tools import get_acls +from pcs.lib.cib.tools import ( + IdProvider, + get_acls, +) +from pcs.lib.env import LibraryEnvironment +from pcs.lib.errors import LibraryError + +if TYPE_CHECKING: + from pcs.common import reports @contextmanager @@ -10,7 +19,12 @@ def cib_acl_section(env): env.push_cib() -def create_role(lib_env, role_id, permission_info_list, description): +def create_role( + lib_env: LibraryEnvironment, + role_id: str, + permission_info_list: acl.PermissionInfoList, + description: str, +) -> None: """ Create new acl role. Raises LibraryError on any failure. @@ -22,11 +36,22 @@ def create_role(lib_env, role_id, permission_info_list, description): description -- text description for role """ with cib_acl_section(lib_env) as acl_section: + id_provider = IdProvider(acl_section) + report_list = acl.validate_create_role( + id_provider, role_id, description + ) if permission_info_list: - acl.validate_permissions(acl_section, permission_info_list) + report_list += acl.validate_permissions( + acl_section, permission_info_list + ) + if lib_env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + role_el = acl.create_role(acl_section, role_id, description) if permission_info_list: - acl.add_permissions_to_role(role_el, permission_info_list) + acl.add_permissions_to_role( + role_el, permission_info_list, id_provider + ) def remove_role(lib_env, role_id, autodelete_users_groups=False): @@ -219,7 +244,11 @@ def remove_group(lib_env, group_id): acl.remove_group(acl_section, group_id) -def add_permission(lib_env, role_id, permission_info_list): +def add_permission( + lib_env: LibraryEnvironment, + role_id: str, + permission_info_list: acl.PermissionInfoList, +) -> None: """ Add permissions to a role with id role_id. If role doesn't exist it will be created. @@ -231,10 +260,21 @@ def add_permission(lib_env, role_id, permission_info_list): (, , ) """ with cib_acl_section(lib_env) as acl_section: - acl.validate_permissions(acl_section, permission_info_list) - acl.add_permissions_to_role( - acl.provide_role(acl_section, role_id), permission_info_list + report_list: reports.ReportItemList = [] + id_provider = IdProvider(acl_section) + + role_el = acl.find_role(acl_section, role_id, none_if_id_unused=True) + if role_el is None: + report_list += acl.validate_create_role(id_provider, role_id) + report_list += acl.validate_permissions( + acl_section, permission_info_list ) + if lib_env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + + if role_el is None: + role_el = acl.create_role(acl_section, role_id) + acl.add_permissions_to_role(role_el, permission_info_list, id_provider) def remove_permission(lib_env, permission_id): diff --git a/pcs/lib/commands/alert.py b/pcs/lib/commands/alert.py index 13e172ba6..321d3b79d 100644 --- a/pcs/lib/commands/alert.py +++ b/pcs/lib/commands/alert.py @@ -1,24 +1,40 @@ -from pcs.common import reports -from pcs.common.reports import ReportItemList -from pcs.common.reports.item import ReportItem +from typing import TYPE_CHECKING, Any, Mapping, Optional + +from pcs.common.pacemaker.alert import CibAlertListDto +from pcs.common.types import StringIterable from pcs.lib.cib import alert from pcs.lib.cib.nvpair import ( arrange_first_instance_attributes, arrange_first_meta_attributes, ) -from pcs.lib.cib.tools import IdProvider +from pcs.lib.cib.rule.in_effect import get_rule_evaluator +from pcs.lib.cib.tools import ( + IdProvider, + get_alerts, +) from pcs.lib.env import LibraryEnvironment from pcs.lib.errors import LibraryError +if TYPE_CHECKING: + from pcs.common.reports import ReportItemList + def create_alert( + # Path is mandatory, so it should not be optional. However, the current + # code calling this function does not prevent it being None. The idea behind + # that was, that the validation would happen in the lib command. Since + # then, however, the paradigma got changed as we found out that a client + # should actually be responsible for providing all mandatory parameters. + # The interface cannot be simply changed, as backward compatibility must be + # maintained for lib.commands. We still want to change it, but it needs to + # be done in the proper way. lib_env: LibraryEnvironment, - alert_id, - path, - instance_attribute_dict, - meta_attribute_dict, - description=None, -): + alert_id: Optional[str], + path: Optional[str], + instance_attribute_dict: Mapping[str, str], + meta_attribute_dict: Mapping[str, str], + description: Optional[str] = None, +) -> None: """ Create new alert. Raises LibraryError if path is not specified, or any other failure. @@ -30,16 +46,22 @@ def create_alert( meta_attribute_dict -- dictionary of meta attributes description -- alert description description """ - if not path: - raise LibraryError( - ReportItem.error( - reports.messages.RequiredOptionsAreMissing(["path"]) - ) - ) - cib = lib_env.get_cib() id_provider = IdProvider(cib) - alert_el = alert.create_alert(cib, alert_id, path, description) + + lib_env.report_processor.report_list( + alert.validate_create_alert(id_provider, path, alert_id) + ) + if lib_env.report_processor.has_errors: + raise LibraryError() + + alert_el = alert.create_alert( + cib, + id_provider, + str(path), # if path were None, validation above would raise + alert_id, + description, + ) arrange_first_instance_attributes( alert_el, instance_attribute_dict, id_provider ) @@ -50,12 +72,12 @@ def create_alert( def update_alert( lib_env: LibraryEnvironment, - alert_id, - path, - instance_attribute_dict, - meta_attribute_dict, - description=None, -): + alert_id: str, + path: Optional[str], + instance_attribute_dict: Mapping[str, str], + meta_attribute_dict: Mapping[str, str], + description: Optional[str] = None, +) -> None: """ Update existing alert with specified id. @@ -79,7 +101,9 @@ def update_alert( lib_env.push_cib() -def remove_alert(lib_env: LibraryEnvironment, alert_id_list): +def remove_alert( + lib_env: LibraryEnvironment, alert_id_list: StringIterable +) -> None: """ Remove alerts with specified ids. @@ -100,15 +124,24 @@ def remove_alert(lib_env: LibraryEnvironment, alert_id_list): def add_recipient( + # Recipient value is mandatory, so it should not be optional. However, the + # current code calling this function does not prevent it being None. The + # idea behind that was, that the validation would happen in the lib + # command. Since then, however, the paradigma got changed as we found out + # that a client should actually be responsible for providing all mandatory + # parameters. + # The interface cannot be simply changed, as backward compatibility must be + # maintained for lib.commands. We still want to change it, but it needs to + # be done in the proper way. lib_env: LibraryEnvironment, - alert_id, - recipient_value, - instance_attribute_dict, - meta_attribute_dict, - recipient_id=None, - description=None, - allow_same_value=False, -): + alert_id: str, + recipient_value: Optional[str], + instance_attribute_dict: Mapping[str, str], + meta_attribute_dict: Mapping[str, str], + recipient_id: Optional[str] = None, + description: Optional[str] = None, + allow_same_value: bool = False, +) -> None: """ Add new recipient to alert witch id alert_id. @@ -121,41 +154,49 @@ def add_recipient( description -- recipient description allow_same_value -- if True unique recipient value is not required """ - if not recipient_value: - raise LibraryError( - ReportItem.error( - reports.messages.RequiredOptionsAreMissing(["value"]) - ) - ) - cib = lib_env.get_cib() id_provider = IdProvider(cib) - recipient = alert.add_recipient( - lib_env.report_processor, - cib, - alert_id, - recipient_value, - recipient_id=recipient_id, + alert_el = alert.find_alert(get_alerts(cib), alert_id) + + lib_env.report_processor.report_list( + alert.validate_add_recipient( + id_provider, + alert_el, + recipient_value, + recipient_id, + allow_same_value=allow_same_value, + ) + ) + if lib_env.report_processor.has_errors: + raise LibraryError() + + recipient_el = alert.add_recipient( + id_provider, + alert_el, + # if recipient_value were None, validation above would raise + str(recipient_value), + recipient_id, description=description, - allow_same_value=allow_same_value, ) arrange_first_instance_attributes( - recipient, instance_attribute_dict, id_provider + recipient_el, instance_attribute_dict, id_provider + ) + arrange_first_meta_attributes( + recipient_el, meta_attribute_dict, id_provider ) - arrange_first_meta_attributes(recipient, meta_attribute_dict, id_provider) lib_env.push_cib() def update_recipient( lib_env: LibraryEnvironment, - recipient_id, - instance_attribute_dict, - meta_attribute_dict, - recipient_value=None, - description=None, - allow_same_value=False, -): + recipient_id: str, + instance_attribute_dict: Mapping[str, str], + meta_attribute_dict: Mapping[str, str], + recipient_value: Optional[str] = None, + description: Optional[str] = None, + allow_same_value: bool = False, +) -> None: """ Update existing recipient. @@ -169,21 +210,24 @@ def update_recipient( deleted, if None old value will stay unchanged allow_same_value -- if True unique recipient value is not required """ - if not recipient_value and recipient_value is not None: - raise LibraryError( - ReportItem.error( - reports.messages.CibAlertRecipientValueInvalid(recipient_value) - ) - ) cib = lib_env.get_cib() id_provider = IdProvider(cib) + recipient_el = alert.find_recipient(get_alerts(cib), recipient_id) + + lib_env.report_processor.report_list( + alert.validate_update_recipient( + recipient_el, + recipient_value, + allow_same_value=allow_same_value, + ) + ) + if lib_env.report_processor.has_errors: + raise LibraryError() + recipient = alert.update_recipient( - lib_env.report_processor, - cib, - recipient_id, + recipient_el, recipient_value=recipient_value, description=description, - allow_same_value=allow_same_value, ) arrange_first_instance_attributes( recipient, instance_attribute_dict, id_provider @@ -193,7 +237,9 @@ def update_recipient( lib_env.push_cib() -def remove_recipient(lib_env: LibraryEnvironment, recipient_id_list): +def remove_recipient( + lib_env: LibraryEnvironment, recipient_id_list: StringIterable +) -> None: """ Remove specified recipients. @@ -212,11 +258,27 @@ def remove_recipient(lib_env: LibraryEnvironment, recipient_id_list): lib_env.push_cib() -def get_all_alerts(lib_env: LibraryEnvironment): +def get_config_dto( + lib_env: LibraryEnvironment, evaluate_expired: bool = False +) -> CibAlertListDto: + cib = lib_env.get_cib() + rule_in_effect_eval = get_rule_evaluator( + cib, lib_env.cmd_runner(), lib_env.report_processor, evaluate_expired + ) + return CibAlertListDto( + [ + alert.alert_el_to_dto(alert_el, rule_eval=rule_in_effect_eval) + for alert_el in alert.get_all_alert_elements(get_alerts(cib)) + ] + ) + + +# DEPRECATED, use get_config_dto +def get_all_alerts(lib_env: LibraryEnvironment) -> list[dict[str, Any]]: """ Returns list of all alerts. See docs of pcs.lib.cib.alert.get_all_alerts for description of data format. lib_env -- LibraryEnvironment """ - return alert.get_all_alerts(lib_env.get_cib()) + return alert.get_all_alerts_dict(lib_env.get_cib()) diff --git a/pcs/lib/commands/booth.py b/pcs/lib/commands/booth.py index c961705bd..220edc832 100644 --- a/pcs/lib/commands/booth.py +++ b/pcs/lib/commands/booth.py @@ -1,10 +1,9 @@ import base64 import os.path from functools import partial -from typing import ( - Optional, - cast, -) +from typing import Mapping, Optional, cast + +from lxml.etree import _Element from pcs import settings from pcs.common import ( @@ -23,6 +22,7 @@ ) from pcs.common.services.errors import ManageServiceError from pcs.common.str_tools import join_multilines +from pcs.common.types import StringSequence from pcs.lib import ( tools, validate, @@ -34,6 +34,12 @@ resource, status, ) +from pcs.lib.booth.cib import get_ticket_names as get_cib_ticket_names +from pcs.lib.booth.env import BoothEnv +from pcs.lib.cib.remove_elements import ( + ElementsToRemove, + remove_specified_elements, +) from pcs.lib.cib.resource import ( group, hierarchy, @@ -43,6 +49,7 @@ IdProvider, get_resources, ) +from pcs.lib.commands.cib import _stop_resources_wait from pcs.lib.communication.booth import ( BoothGetConfig, BoothSendConfig, @@ -50,6 +57,7 @@ from pcs.lib.communication.tools import run_and_raise from pcs.lib.env import LibraryEnvironment from pcs.lib.errors import LibraryError +from pcs.lib.external import CommandRunner from pcs.lib.file.instance import FileInstance from pcs.lib.file.raw_file import ( GhostFile, @@ -58,6 +66,13 @@ ) from pcs.lib.interface.config import ParserErrorException from pcs.lib.node import get_existing_nodes_names +from pcs.lib.pacemaker.live import ( + has_cib_xml, + resource_restart, +) +from pcs.lib.pacemaker.live import ticket_cleanup as live_ticket_cleanup +from pcs.lib.pacemaker.live import ticket_standby as live_ticket_standby +from pcs.lib.pacemaker.live import ticket_unstandby as live_ticket_unstandby from pcs.lib.resource_agent import ( ResourceAgentError, ResourceAgentFacade, @@ -75,19 +90,19 @@ def config_setup( env: LibraryEnvironment, - site_list, - arbitrator_list, - instance_name=None, - overwrite_existing=False, -): + site_list: StringSequence, + arbitrator_list: StringSequence, + instance_name: Optional[str] = None, + overwrite_existing: bool = False, +) -> None: """ create booth configuration env - list site_list -- site addresses of multisite - list arbitrator_list -- arbitrator addresses of multisite - string instance_name -- booth instance name - bool overwrite_existing -- allow overwriting existing files + site_list -- site addresses of multisite + arbitrator_list -- arbitrator addresses of multisite + instance_name -- booth instance name + overwrite_existing -- allow overwriting existing files """ instance_name = instance_name or constants.DEFAULT_INSTANCE_NAME report_processor = env.report_processor @@ -146,7 +161,7 @@ def config_setup( raise LibraryError() -def config_destroy( +def config_destroy( # noqa: PLR0912 env: LibraryEnvironment, instance_name: Optional[str] = None, ignore_config_load_problems: bool = False, @@ -165,20 +180,30 @@ def config_destroy( found_instance_name = booth_env.instance_name _ensure_live_env(env, booth_env) - booth_resource_list = resource.find_for_config( - get_resources(env.get_cib()), - booth_env.config_path, - ) - if booth_resource_list: - report_processor.report( - ReportItem.error( - reports.messages.BoothConfigIsUsed( - found_instance_name, - reports.const.BOOTH_CONFIG_USED_IN_CLUSTER_RESOURCE, - resource_name=str(booth_resource_list[0].get("id", "")), + if ( + has_cib_xml() + or env.service_manager.is_running("pacemaker") + or env.service_manager.is_running("pacemaker_remoted") + ): + # To allow destroying booth config on arbitrators, only check CIB if: + # * pacemaker is running and therefore we are able to get CIB + # * CIB is stored on disk - pcmk is not running but the node is in a + # cluster (don't checking corosync to cover remote and guest nodes) + # If CIB cannot be loaded in either case, fail with an error. + booth_resource_list = resource.find_for_config( + get_resources(env.get_cib()), + booth_env.config_path, + ) + if booth_resource_list: + report_processor.report( + ReportItem.error( + reports.messages.BoothConfigIsUsed( + found_instance_name, + reports.const.BOOTH_CONFIG_USED_IN_CLUSTER_RESOURCE, + resource_name=str(booth_resource_list[0].get("id", "")), + ) ) ) - ) # Only systemd is currently supported. Initd does not supports multiple # instances (here specified by name) if is_systemd(env.service_manager): @@ -271,7 +296,7 @@ def config_destroy( # TODO: remove once settings booth_enable_autfile_(set|unset)_enabled are removed def _config_set_enable_authfile( - env: LibraryEnvironment, value: bool, instance_name=None + env: LibraryEnvironment, value: bool, instance_name: Optional[str] = None ) -> None: report_processor = env.report_processor booth_env = env.get_booth_env(instance_name) @@ -296,24 +321,28 @@ def _config_set_enable_authfile( def config_set_enable_authfile( - env: LibraryEnvironment, instance_name=None + env: LibraryEnvironment, instance_name: Optional[str] = None ) -> None: _config_set_enable_authfile(env, True, instance_name=instance_name) def config_unset_enable_authfile( - env: LibraryEnvironment, instance_name=None + env: LibraryEnvironment, instance_name: Optional[str] = None ) -> None: _config_set_enable_authfile(env, False, instance_name=instance_name) -def config_text(env: LibraryEnvironment, instance_name=None, node_name=None): +def config_text( + env: LibraryEnvironment, + instance_name: Optional[str] = None, + node_name: Optional[str] = None, +) -> str: """ get configuration in raw format env - string instance_name -- booth instance name - string node_name -- get the config from specified node or local host if None + instance_name -- booth instance name + node_name -- get the config from specified node or local host if None """ report_processor = env.report_processor booth_env = env.get_booth_env(instance_name) @@ -334,7 +363,7 @@ def config_text(env: LibraryEnvironment, instance_name=None, node_name=None): com_cmd = BoothGetConfig(env.report_processor, instance_name) com_cmd.set_targets( - [env.get_node_target_factory().get_target_from_hostname(node_name)] + [env.get_node_target_factory().get_target_from_hostname(str(node_name))] ) remote_data = run_and_raise(env.get_node_communicator(), com_cmd)[0][1] try: @@ -343,25 +372,27 @@ def config_text(env: LibraryEnvironment, instance_name=None, node_name=None): return remote_data["config"]["data"].encode("utf-8") except KeyError as e: raise LibraryError( - ReportItem.error(reports.messages.InvalidResponseFormat(node_name)) + ReportItem.error( + reports.messages.InvalidResponseFormat(str(node_name)) + ) ) from e def config_ticket_add( env: LibraryEnvironment, - ticket_name, - options, - instance_name=None, - allow_unknown_options=False, -): + ticket_name: str, + options: validate.TypeOptionMap, + instance_name: Optional[str] = None, + allow_unknown_options: bool = False, +) -> None: """ add a ticket to booth configuration env - string ticket_name -- the name of the ticket to be created - dict options -- options for the ticket - string instance_name -- booth instance name - bool allow_unknown_options -- allow using options unknown to pcs + ticket_name -- the name of the ticket to be created + options -- options for the ticket + instance_name -- booth instance name + allow_unknown_options -- allow using options unknown to pcs """ report_processor = env.report_processor booth_env = env.get_booth_env(instance_name) @@ -396,15 +427,15 @@ def config_ticket_add( def config_ticket_remove( env: LibraryEnvironment, - ticket_name, - instance_name=None, -): + ticket_name: str, + instance_name: Optional[str] = None, +) -> None: """ remove a ticket from booth configuration env - string ticket_name -- the name of the ticket to be removed - string instance_name -- booth instance name + ticket_name -- the name of the ticket to be removed + instance_name -- booth instance name """ report_processor = env.report_processor booth_env = env.get_booth_env(instance_name) @@ -432,7 +463,7 @@ def create_in_cluster( ip: str, instance_name: Optional[str] = None, allow_absent_resource_agent: bool = False, -): +) -> None: """ Create group with ip resource and booth resource @@ -516,76 +547,92 @@ def create_in_cluster( def remove_from_cluster( env: LibraryEnvironment, - resource_remove, - instance_name=None, - allow_remove_multiple=False, -): + instance_name: Optional[str] = None, + force_flags: reports.types.ForceFlags = (), +) -> None: """ Remove group with ip resource and booth resource env -- provides all for communication with externals - function resource_remove -- provisional hack til resources are moved to lib - string instance_name -- booth instance name - bool allow_remove_multiple -- remove all resources if more than one found + instance_name -- booth instance name + force_flags -- list of flags codes """ - # TODO resource_remove is provisional hack til resources are moved to lib report_processor = env.report_processor booth_env = env.get_booth_env(instance_name) # This command does not work with booth config files at all, let's reject # them then. _ensure_live_booth_env(booth_env) - resource.get_remover(resource_remove)( + cib = env.get_cib() + booth_elements_to_remove = resource.find_elements_to_remove( _find_resource_elements_for_operation( report_processor, - get_resources(env.get_cib()), + get_resources(cib), booth_env, - allow_remove_multiple, + allow_multiple=reports.codes.FORCE in force_flags, + ) + ) + + resource_ids = [str(el.attrib["id"]) for el in booth_elements_to_remove] + elements_to_remove = ElementsToRemove(cib, resource_ids) + + report_processor.report( + reports.ReportItem.info( + reports.messages.CibRemoveResources(resource_ids) ) ) + report_processor.report_list( + elements_to_remove.dependant_elements.to_reports() + ) + report_processor.report_list( + elements_to_remove.element_references.to_reports() + ) + + cib = _stop_resources_wait( + env, cib, elements_to_remove.resources_to_remove, force_flags + ) + + remove_specified_elements(cib, elements_to_remove) + env.push_cib() def restart( env: LibraryEnvironment, - resource_restart, - instance_name=None, - allow_multiple=False, -): + instance_name: Optional[str] = None, + allow_multiple: bool = False, +) -> None: """ Restart group with ip resource and booth resource env -- provides all for communication with externals - function resource_restart -- provisional hack til resources are moved to lib - string instance_name -- booth instance name - bool allow_remove_multiple -- remove all resources if more than one found + instance_name -- booth instance name + allow_multiple -- restart all resources if more than one found """ - # TODO resource_remove is provisional hack til resources are moved to lib - report_processor = env.report_processor booth_env = env.get_booth_env(instance_name) _ensure_live_env(env, booth_env) for booth_element in _find_resource_elements_for_operation( - report_processor, + env.report_processor, get_resources(env.get_cib()), booth_env, allow_multiple, ): - resource_restart([booth_element.attrib["id"]]) + resource_restart(env.cmd_runner(), str(booth_element.attrib["id"])) def ticket_grant( env: LibraryEnvironment, - ticket_name, - site_ip=None, - instance_name=None, -): + ticket_name: str, + site_ip: Optional[str] = None, + instance_name: Optional[str] = None, +) -> None: """ Grant a ticket to the site specified by site_ip env - string ticket_name -- the name of the ticket to be granted - string site_ip -- IP of the site to grant the ticket to, None for local - string instance_name -- booth instance name + ticket_name -- the name of the ticket to be granted + site_ip -- IP of the site to grant the ticket to, None for local + instance_name -- booth instance name """ return _ticket_operation( "grant", @@ -598,17 +645,17 @@ def ticket_grant( def ticket_revoke( env: LibraryEnvironment, - ticket_name, - site_ip=None, - instance_name=None, -): + ticket_name: str, + site_ip: Optional[str] = None, + instance_name: Optional[str] = None, +) -> None: """ Revoke a ticket from the site specified by site_ip env - string ticket_name -- the name of the ticket to be revoked - string site_ip -- IP of the site to revoke the ticket from, None for local - string instance_name -- booth instance name + ticket_name -- the name of the ticket to be revoked + site_ip -- IP of the site to revoke the ticket from, None for local + instance_name -- booth instance name """ return _ticket_operation( "revoke", @@ -620,8 +667,12 @@ def ticket_revoke( def _ticket_operation( - operation, env: LibraryEnvironment, ticket_name, site_ip, instance_name -): + operation: str, + env: LibraryEnvironment, + ticket_name: str, + site_ip: Optional[str], + instance_name: Optional[str], +) -> None: booth_env = env.get_booth_env(instance_name) _ensure_live_env(env, booth_env) @@ -660,16 +711,149 @@ def _ticket_operation( ) +def ticket_cleanup(env: LibraryEnvironment, ticket_name: str) -> None: + """ + Remove specified booth ticket from CIB on local site + + ticket_name -- name of the ticket to remove + """ + _ensure_live_cib(env) + + report_processor = env.report_processor + + if report_processor.report_list( + _validate_ticket_in_cib(env.get_cib(), ticket_name) + ).has_errors: + raise LibraryError() + + cmd_runner = env.cmd_runner() + + # standby the ticket first, so the node is not fenced if ticket-loss + # policy is set to 'fence' in the ticket constraint + report_processor.report_list(_ticket_standby(cmd_runner, ticket_name)) + if report_processor.has_errors: + raise LibraryError() + + report_processor.report( + reports.ReportItem.info( + reports.messages.BoothTicketCleanup(ticket_name) + ) + ) + stdout, stderr, retval = live_ticket_cleanup(cmd_runner, ticket_name) + if retval != 0: + report_processor.report( + reports.ReportItem.error( + reports.messages.BoothTicketOperationFailed( + "cleanup", + join_multilines([stderr, stdout]), + None, + ticket_name, + ) + ) + ) + + if report_processor.has_errors: + raise LibraryError() + + +def ticket_standby(env: LibraryEnvironment, ticket_name: str) -> None: + """ + Change state of the ticket to standby + + ticket_name -- name of the ticket + """ + _ensure_live_cib(env) + if env.report_processor.report_list( + _validate_ticket_in_cib(env.get_cib(), ticket_name) + ).has_errors: + raise LibraryError() + + if env.report_processor.report_list( + _ticket_standby(env.cmd_runner(), ticket_name) + ).has_errors: + raise LibraryError() + + +def _ticket_standby( + cmd_runner: CommandRunner, ticket: str +) -> reports.ReportItemList: + report_list = [ + reports.ReportItem.info( + reports.messages.BoothTicketChangingState(ticket, "standby") + ) + ] + + stdout, stderr, retval = live_ticket_standby(cmd_runner, ticket) + if retval != 0: + report_list.append( + reports.ReportItem.error( + reports.messages.BoothTicketOperationFailed( + "standby", join_multilines([stderr, stdout]), None, ticket + ) + ) + ) + + return report_list + + +def ticket_unstandby(env: LibraryEnvironment, ticket_name: str) -> None: + """ + Change state of the ticket to active + + ticket_name -- name of the ticket + """ + _ensure_live_cib(env) + if env.report_processor.report_list( + _validate_ticket_in_cib(env.get_cib(), ticket_name) + ).has_errors: + raise LibraryError() + + env.report_processor.report( + reports.ReportItem.info( + reports.messages.BoothTicketChangingState(ticket_name, "active") + ) + ) + stdout, stderr, retval = live_ticket_unstandby( + env.cmd_runner(), ticket_name + ) + if retval != 0: + env.report_processor.report( + reports.ReportItem.error( + reports.messages.BoothTicketOperationFailed( + "unstandby", + join_multilines([stderr, stdout]), + None, + ticket_name, + ) + ) + ) + if env.report_processor.has_errors: + raise LibraryError() + + +def _validate_ticket_in_cib( + cib: _Element, ticket_name: str +) -> reports.ReportItemList: + if ticket_name not in set(get_cib_ticket_names(cib)): + return [ + reports.ReportItem.error( + reports.messages.BoothTicketNotInCib(ticket_name) + ) + ] + + return [] + + def config_sync( env: LibraryEnvironment, - instance_name=None, - skip_offline_nodes=False, -): + instance_name: Optional[str] = None, + skip_offline_nodes: bool = False, +) -> None: """ Send specified local booth configuration to all nodes in the local cluster. env - string instance_name -- booth instance name + instance_name -- booth instance name skip_offline_nodes -- if True offline nodes will be skipped """ report_processor = env.report_processor @@ -732,12 +916,14 @@ def config_sync( run_and_raise(env.get_node_communicator(), com_cmd) -def enable_booth(env: LibraryEnvironment, instance_name=None): +def enable_booth( + env: LibraryEnvironment, instance_name: Optional[str] = None +) -> None: """ Enable specified instance of booth service, systemd systems supported only. env - string instance_name -- booth instance name + instance_name -- booth instance name """ ensure_is_systemd(env.service_manager) booth_env = env.get_booth_env(instance_name) @@ -759,12 +945,14 @@ def enable_booth(env: LibraryEnvironment, instance_name=None): ) -def disable_booth(env: LibraryEnvironment, instance_name=None): +def disable_booth( + env: LibraryEnvironment, instance_name: Optional[str] = None +) -> None: """ Disable specified instance of booth service, systemd systems supported only. env - string instance_name -- booth instance name + instance_name -- booth instance name """ ensure_is_systemd(env.service_manager) booth_env = env.get_booth_env(instance_name) @@ -786,14 +974,16 @@ def disable_booth(env: LibraryEnvironment, instance_name=None): ) -def start_booth(env: LibraryEnvironment, instance_name=None): +def start_booth( + env: LibraryEnvironment, instance_name: Optional[str] = None +) -> None: """ Start specified instance of booth service, systemd systems supported only. On non-systemd systems it can be run like this: BOOTH_CONF_FILE= /etc/initd/booth-arbitrator env - string instance_name -- booth instance name + instance_name -- booth instance name """ ensure_is_systemd(env.service_manager) booth_env = env.get_booth_env(instance_name) @@ -815,12 +1005,14 @@ def start_booth(env: LibraryEnvironment, instance_name=None): ) -def stop_booth(env: LibraryEnvironment, instance_name=None): +def stop_booth( + env: LibraryEnvironment, instance_name: Optional[str] = None +) -> None: """ Stop specified instance of booth service, systemd systems supported only. env - string instance_name -- booth instance name + instance_name -- booth instance name """ ensure_is_systemd(env.service_manager) booth_env = env.get_booth_env(instance_name) @@ -842,14 +1034,16 @@ def stop_booth(env: LibraryEnvironment, instance_name=None): ) -def pull_config(env: LibraryEnvironment, node_name, instance_name=None): +def pull_config( + env: LibraryEnvironment, node_name: str, instance_name: Optional[str] = None +) -> None: """ Get config from specified node and save it on local system. It will rewrite existing files. env - string node_name -- name of the node from which the config should be fetched - string instance_name -- booth instance name + node_name -- name of the node from which the config should be fetched + instance_name -- booth instance name """ report_processor = env.report_processor booth_env = env.get_booth_env(instance_name) @@ -910,12 +1104,14 @@ def pull_config(env: LibraryEnvironment, node_name, instance_name=None): raise LibraryError() -def get_status(env: LibraryEnvironment, instance_name=None): +def get_status( + env: LibraryEnvironment, instance_name: Optional[str] = None +) -> Mapping[str, str]: """ get booth status info env - string instance_name -- booth instance name + instance_name -- booth instance name """ booth_env = env.get_booth_env(instance_name) _ensure_live_env(env, booth_env) @@ -934,10 +1130,10 @@ def get_status(env: LibraryEnvironment, instance_name=None): def _find_resource_elements_for_operation( report_processor: ReportProcessor, - resources_section, - booth_env, - allow_multiple, -): + resources_section: _Element, + booth_env: BoothEnv, + allow_multiple: bool, +) -> list[_Element]: booth_element_list = resource.find_for_config( resources_section, booth_env.config_path, @@ -967,7 +1163,7 @@ def _find_resource_elements_for_operation( return booth_element_list -def _ensure_live_booth_env(booth_env): +def _ensure_live_booth_env(booth_env: BoothEnv) -> None: if booth_env.ghost_file_codes: raise LibraryError( ReportItem.error( @@ -978,7 +1174,18 @@ def _ensure_live_booth_env(booth_env): ) -def _ensure_live_env(env: LibraryEnvironment, booth_env): +def _ensure_live_cib(env: LibraryEnvironment) -> None: + if not env.is_cib_live: + env.report_processor.report( + ReportItem.error( + reports.messages.LiveEnvironmentRequired([file_type_codes.CIB]) + ) + ) + if env.report_processor.has_errors: + raise LibraryError() + + +def _ensure_live_env(env: LibraryEnvironment, booth_env: BoothEnv) -> None: not_live = ( booth_env.ghost_file_codes + diff --git a/pcs/lib/commands/cib.py b/pcs/lib/commands/cib.py index cba5b863a..f86341061 100644 --- a/pcs/lib/commands/cib.py +++ b/pcs/lib/commands/cib.py @@ -1,39 +1,41 @@ from typing import ( - Collection, Iterable, + Sequence, ) -from lxml import etree from lxml.etree import _Element from pcs.common import reports -from pcs.common.types import StringCollection -from pcs.lib.cib.const import TAG_OBJREF -from pcs.lib.cib.constraint.common import is_constraint -from pcs.lib.cib.constraint.location import ( - is_location_constraint, - is_location_rule, +from pcs.common.types import ( + StringCollection, + StringSequence, ) -from pcs.lib.cib.constraint.resource_set import is_set_constraint -from pcs.lib.cib.resource.bundle import is_bundle -from pcs.lib.cib.resource.clone import is_any_clone -from pcs.lib.cib.resource.common import get_inner_resources -from pcs.lib.cib.resource.group import is_group -from pcs.lib.cib.tag import is_tag -from pcs.lib.cib.tools import ( - find_elements_referencing_id, - get_elements_by_ids, - remove_element_by_id, +from pcs.lib.cib.remove_elements import ( + ElementsToRemove, + ensure_resources_stopped, + remove_specified_elements, + stop_resources, + warn_resource_unmanaged, ) +from pcs.lib.cib.resource.guest_node import ( + get_node_name_from_resource as get_node_name_from_guest_resource, +) +from pcs.lib.cib.resource.guest_node import is_guest_node +from pcs.lib.cib.resource.remote_node import ( + get_node_name_from_resource as get_node_name_from_remote_resource, +) +from pcs.lib.cib.resource.stonith import is_stonith +from pcs.lib.cib.tools import get_resources from pcs.lib.env import LibraryEnvironment from pcs.lib.errors import LibraryError -from pcs.lib.pacemaker.live import parse_cib_xml +from pcs.lib.pacemaker.live import remove_node +from pcs.lib.sbd_stonith import ensure_some_stonith_remains def remove_elements( env: LibraryEnvironment, ids: StringCollection, - force_flags: Collection[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: """ Remove elements with specified ids from CIB. This function is aware of @@ -43,140 +45,165 @@ def remove_elements( ids -- ids of configuration elements to remove force_flags -- list of flags codes """ - del force_flags - id_set = set(ids) cib = env.get_cib() - wip_cib = parse_cib_xml(etree.tostring(cib).decode()) report_processor = env.report_processor - elements_to_process, not_found_ids = get_elements_by_ids(wip_cib, id_set) + elements_to_remove = ElementsToRemove(cib, ids) + remote_node_names = _get_remote_node_names( + elements_to_remove.resources_to_remove + ) + guest_node_names = _get_guest_node_names( + elements_to_remove.resources_to_remove + ) - for non_existing_id in not_found_ids: + if remote_node_names: report_processor.report( - reports.ReportItem.error( - reports.messages.IdNotFound( - non_existing_id, ["configuration element"] - ) + reports.ReportItem.deprecation( + reports.messages.UseCommandNodeRemoveRemote() ) ) - - for element in elements_to_process: - # TODO: add support for other CIB elements - if not (is_constraint(element) or is_location_rule(element)): - report_processor.report( - reports.ReportItem.error( - reports.messages.IdBelongsToUnexpectedType( - str(element.get("id")), - ["constraint", "location rule"], - element.tag, - ) - ) + if guest_node_names: + report_processor.report( + reports.ReportItem.deprecation( + reports.messages.UseCommandNodeRemoveGuest() ) + ) - if report_processor.has_errors: + if report_processor.report_list( + _validate_elements_to_remove(elements_to_remove) + + _warn_remote_guest(remote_node_names, guest_node_names) + + ensure_some_stonith_remains( + env, + get_resources(cib), + stonith_resources_to_ignore=[ + str(res_el.attrib["id"]) + for res_el in elements_to_remove.resources_to_remove + if is_stonith(res_el) + ], + sbd_being_disabled=False, + force_flags=force_flags, + ) + ).has_errors: raise LibraryError() - element_ids_to_remove = _get_dependencies_to_remove(elements_to_process) - dependant_elements, _ = get_elements_by_ids( - cib, element_ids_to_remove - id_set + report_processor.report_list( + elements_to_remove.dependant_elements.to_reports() ) - if dependant_elements: - report_processor.report( - reports.ReportItem.info( - reports.messages.CibRemoveDependantElements( - { - str(element.attrib["id"]): element.tag - for element in dependant_elements - } - ) + report_processor.report_list( + elements_to_remove.element_references.to_reports() + ) + + cib = _stop_resources_wait( + env, cib, elements_to_remove.resources_to_remove, force_flags + ) + + remove_specified_elements(cib, elements_to_remove) + env.push_cib() + + if env.is_cib_live: + for node_name in remote_node_names + guest_node_names: + remove_node(env.cmd_runner(), node_name) + + +def _stop_resources_wait( + env: LibraryEnvironment, + cib: _Element, + resource_elements: Sequence[_Element], + force_flags: reports.types.ForceFlags = (), +) -> _Element: + """ + Stop all resources that are going to be removed. Push cib, wait for the + cluster to settle down, and check if all resources were properly stopped. + If not, report errors. Return cib with the applied changes. + + cib -- whole cib + resource_elements -- resources that should be stopped + force_flags -- list of flags codes + """ + if not resource_elements: + return cib + if not env.is_cib_live: + return cib + if reports.codes.FORCE in force_flags: + env.report_processor.report( + reports.ReportItem.warning( + reports.messages.StoppingResourcesBeforeDeletingSkipped() ) ) + return cib + + resource_ids = [str(el.attrib["id"]) for el in resource_elements] - for element_id in element_ids_to_remove: - remove_element_by_id(cib, element_id) + env.report_processor.report( + reports.ReportItem.info( + reports.messages.StoppingResourcesBeforeDeleting(resource_ids) + ) + ) + if env.report_processor.report_list( + warn_resource_unmanaged(env.get_cluster_state(), resource_ids) + ).has_errors: + raise LibraryError() + stop_resources(cib, resource_elements) env.push_cib() + env.wait_for_idle() + if env.report_processor.report_list( + ensure_resources_stopped(env.get_cluster_state(), resource_ids) + ).has_errors: + raise LibraryError() -def _get_dependencies_to_remove(elements: list[_Element]) -> set[str]: - """ - Get ids of all elements that need to be removed (including specified - elements) together with specified elements based on their relations. + return env.get_cib() + + +def _validate_elements_to_remove( + element_to_remove: ElementsToRemove, +) -> reports.ReportItemList: + report_list = [ + reports.ReportItem.error(reports.messages.IdNotFound(missing_id, [])) + for missing_id in sorted(element_to_remove.missing_ids) + ] + unsupported_elements = element_to_remove.unsupported_elements + report_list.extend( + reports.ReportItem.error( + reports.messages.IdBelongsToUnexpectedType( + unsupported_id, + list(unsupported_elements.supported_element_types), + unsupported_elements.id_tag_map[unsupported_id], + ) + ) + for unsupported_id in sorted(unsupported_elements.id_tag_map) + ) + return report_list - WARNING: this is a destructive operation for elements and their etree. - elements -- list of elements that are planned to be removed - """ - elements_to_process = list(elements) - element_ids_to_remove: set[str] = set() - - while elements_to_process: - el = elements_to_process.pop(0) - element_id = str(el.attrib["id"]) - if el.tag not in ("obj_ref", "resource_ref", "role"): - if element_id in element_ids_to_remove: - continue - element_ids_to_remove.add(element_id) - elements_to_process.extend(_get_element_references(el)) - elements_to_process.extend(_get_inner_references(el)) - parent_el = el.getparent() - if parent_el is not None: - if _is_empty_after_inner_el_removal(parent_el): - elements_to_process.append(parent_el) - parent_el.remove(el) - - return element_ids_to_remove - - -def _get_element_references(element: _Element) -> Iterable[_Element]: - """ - Return all CIB elements that are referencing specified element +def _warn_remote_guest( + remote_node_names: StringSequence, guest_node_names: StringSequence +) -> reports.ReportItemList: + return [ + reports.ReportItem.warning( + reports.messages.RemoteNodeRemovalIncomplete(node_name) + ) + for node_name in remote_node_names + ] + [ + reports.ReportItem.warning( + reports.messages.GuestNodeRemovalIncomplete(node_name) + ) + for node_name in guest_node_names + ] - element -- references to this element will be - """ - return find_elements_referencing_id(element, str(element.attrib["id"])) +def _get_remote_node_names(resource_elements: Iterable[_Element]) -> list[str]: + return [ + get_node_name_from_remote_resource(el) + for el in resource_elements + if get_node_name_from_remote_resource(el) is not None + ] -def _get_inner_references(element: _Element) -> Iterable[_Element]: - """ - Get all inner elements with attribute id, which means that they might be - refernenced in IDREF. Elements with attribute id and type IDREF are also - returned. - - Note: - Only removing of constraint or location rule elements is supported. - Theirs inner elements cannot be referenced or referencing is not - supported. - """ - # pylint: disable=unused-argument - # return cast(Iterable[_Element], element.xpath("./*[@id]")) - # if is_resource(element): - # return get_inner_resources(element) - # if element.tag == "alert": - # return element.findall("recipient") - # if is_set_constraint(element): - # return element.findall("resource_set") - # if element.tag == "acl_role": - # return element.findall("acl_permission") - return [] - - -def _is_last_element(parent_element: _Element, child_tag: str) -> bool: - return len(parent_element.findall(f"./{child_tag}")) == 1 - - -def _is_empty_after_inner_el_removal(parent_el: _Element) -> bool: - # pylint: disable=too-many-return-statements - if is_bundle(parent_el) or is_any_clone(parent_el): - return True - if is_group(parent_el): - return len(get_inner_resources(parent_el)) == 1 - if is_tag(parent_el): - return _is_last_element(parent_el, TAG_OBJREF) - if parent_el.tag == "resource_set": - return _is_last_element(parent_el, "resource_ref") - if is_set_constraint(parent_el): - return _is_last_element(parent_el, "resource_set") - if is_location_constraint(parent_el): - return _is_last_element(parent_el, "rule") - return False + +def _get_guest_node_names(resource_elements: Iterable[_Element]) -> list[str]: + return [ + get_node_name_from_guest_resource(el) + for el in resource_elements + if is_guest_node(el) + ] diff --git a/pcs/lib/commands/cib_options.py b/pcs/lib/commands/cib_options.py index 8630ace01..e997f75e4 100644 --- a/pcs/lib/commands/cib_options.py +++ b/pcs/lib/commands/cib_options.py @@ -1,10 +1,4 @@ -from typing import ( - Any, - Collection, - Container, - Mapping, - Optional, -) +from typing import Any, Mapping, Optional from lxml.etree import _Element @@ -41,7 +35,7 @@ def resource_defaults_create( nvpairs: Mapping[str, str], nvset_options: Mapping[str, str], nvset_rule: Optional[str] = None, - force_flags: Collection[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: """ Create new resource defaults nvset @@ -72,7 +66,7 @@ def operation_defaults_create( nvpairs: Mapping[str, str], nvset_options: Mapping[str, str], nvset_rule: Optional[str] = None, - force_flags: Collection[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: """ Create new operation defaults nvset @@ -105,7 +99,7 @@ def _defaults_create( nvpairs: Mapping[str, str], nvset_options: Mapping[str, str], nvset_rule: Optional[str] = None, - force_flags: Container[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: required_cib_version = None nice_to_have_cib_version = None diff --git a/pcs/lib/commands/cluster.py b/pcs/lib/commands/cluster.py deleted file mode 100644 index 6a72a3dd7..000000000 --- a/pcs/lib/commands/cluster.py +++ /dev/null @@ -1,2317 +0,0 @@ -# pylint: disable=too-many-lines -import math -import os.path -import time -from typing import ( - Any, - Collection, - Mapping, - Optional, - Sequence, - Tuple, - cast, -) - -from pcs import settings -from pcs.common import ( - file_type_codes, - reports, - ssl, -) -from pcs.common.corosync_conf import ( - CorosyncConfDto, - CorosyncQuorumDeviceSettingsDto, -) -from pcs.common.file import RawFileError -from pcs.common.node_communicator import HostNotFound -from pcs.common.reports import ReportProcessor -from pcs.common.reports import codes as report_codes -from pcs.common.reports.item import ( - ReportItem, - ReportItemList, -) -from pcs.common.str_tools import join_multilines -from pcs.common.tools import format_os_error -from pcs.common.types import ( - CorosyncTransportType, - UnknownCorosyncTransportTypeException, -) -from pcs.lib import ( - node_communication_format, - sbd, - validate, -) -from pcs.lib.booth import sync as booth_sync -from pcs.lib.cib import fencing_topology -from pcs.lib.cib.resource.guest_node import find_node_list as get_guest_nodes -from pcs.lib.cib.resource.remote_node import find_node_list as get_remote_nodes -from pcs.lib.cib.tools import ( - get_fencing_topology, - get_resources, -) -from pcs.lib.communication import cluster -from pcs.lib.communication.corosync import ( - CheckCorosyncOffline, - DistributeCorosyncConf, - ReloadCorosyncConf, -) -from pcs.lib.communication.nodes import ( - CheckPacemakerStarted, - DistributeFilesWithoutForces, - EnableCluster, - GetHostInfo, - GetOnlineTargets, - RemoveFilesWithoutForces, - RemoveNodesFromCib, - SendPcsdSslCertAndKey, - StartCluster, - UpdateKnownHosts, -) -from pcs.lib.communication.sbd import ( - CheckSbd, - DisableSbdService, - EnableSbdService, - SetSbdConfig, -) -from pcs.lib.communication.tools import AllSameDataMixin -from pcs.lib.communication.tools import run as run_com -from pcs.lib.communication.tools import run_and_raise -from pcs.lib.corosync import ( - config_facade, - config_parser, - config_validators, -) -from pcs.lib.corosync import constants as corosync_constants -from pcs.lib.corosync import qdevice_net -from pcs.lib.env import ( - LibraryEnvironment, - WaitType, -) -from pcs.lib.errors import LibraryError -from pcs.lib.file.instance import FileInstance -from pcs.lib.interface.config import ParserErrorException -from pcs.lib.node import get_existing_nodes_names -from pcs.lib.pacemaker.live import ( - get_cib, - get_cib_xml, - get_cib_xml_cmd_results, - remove_node, -) -from pcs.lib.pacemaker.live import verify as verify_cmd -from pcs.lib.pacemaker.state import ClusterState -from pcs.lib.pacemaker.values import get_valid_timeout_seconds -from pcs.lib.tools import ( - environment_file_to_dict, - generate_binary_key, - generate_uuid, -) - - -def node_clear( - env: LibraryEnvironment, - node_name, - allow_clear_cluster_node=False, -): - """ - Remove specified node from various cluster caches. - - LibraryEnvironment env provides all for communication with externals - string node_name - bool allow_clear_cluster_node -- flag allows to clear node even if it's - still in a cluster - """ - _ensure_live_env(env) # raises if env is not live - - current_nodes, report_list = get_existing_nodes_names( - env.get_corosync_conf(), env.get_cib() - ) - if env.report_processor.report_list(report_list).has_errors: - raise LibraryError() - - if node_name in current_nodes: - if env.report_processor.report( - ReportItem( - severity=reports.item.get_severity( - report_codes.FORCE, - allow_clear_cluster_node, - ), - message=reports.messages.NodeToClearIsStillInCluster(node_name), - ) - ).has_errors: - raise LibraryError() - - remove_node(env.cmd_runner(), node_name) - - -def verify(env: LibraryEnvironment, verbose=False): - runner = env.cmd_runner() - ( - dummy_stdout, - verify_stderr, - verify_returncode, - can_be_more_verbose, - ) = verify_cmd(runner, verbose=verbose) - - # 1) Do not even try to think about upgrading! - # 2) We do not need cib management in env (no need for push...). - # So env.get_cib is not best choice here (there were considerations to - # upgrade cib at all times inside env.get_cib). Go to a lower level here. - if verify_returncode != 0: - env.report_processor.report( - ReportItem.error( - reports.messages.InvalidCibContent( - verify_stderr, - can_be_more_verbose, - ) - ) - ) - - # Cib is sometimes loadable even if `crm_verify` fails (e.g. when - # fencing topology is invalid). On the other hand cib with id - # duplication is not loadable. - # We try extra checks when cib is possible to load. - cib_xml, dummy_stderr, returncode = get_cib_xml_cmd_results(runner) - if returncode != 0: - raise LibraryError() - else: - cib_xml = get_cib_xml(runner) - - cib = get_cib(cib_xml) - env.report_processor.report_list( - fencing_topology.verify( - get_fencing_topology(cib), - get_resources(cib), - ClusterState(env.get_cluster_state()).node_section.nodes, - ) - ) - if env.report_processor.has_errors: - raise LibraryError() - - -def setup( - env: LibraryEnvironment, - cluster_name: str, - nodes: Sequence[Mapping[str, Any]], - transport_type: Optional[str] = None, - transport_options: Optional[Mapping[str, str]] = None, - link_list: Optional[Sequence[Mapping[str, Any]]] = None, - compression_options: Optional[Mapping[str, str]] = None, - crypto_options: Optional[Mapping[str, str]] = None, - totem_options: Optional[Mapping[str, str]] = None, - quorum_options: Optional[Mapping[str, str]] = None, - wait: WaitType = False, - start: bool = False, - enable: bool = False, - no_keys_sync: bool = False, - no_cluster_uuid: bool = False, - force_flags: Collection[reports.types.ForceCode] = (), -): - # pylint: disable=too-many-arguments - # pylint: disable=too-many-locals - # pylint: disable=too-many-statements - """ - Set up cluster on specified nodes. - Validation of the inputs is done here. Possible existing clusters are - destroyed (when using force). Authkey files for corosync and pacemaker, - known hosts and newly generated corosync.conf are distributed to all - nodes. - Raise LibraryError on any error. - - env - cluster_name -- name of a cluster to set up - nodes -- list of dicts which represents node. - Supported keys are: name (required), addrs. See note below. - transport_type -- transport type of a cluster - transport_options -- transport specific options - link_list -- list of links, depends of transport_type - compression_options -- only available for knet transport. In - corosync.conf they are prefixed 'knet_compression_' - crypto_options -- only available for knet transport'. In corosync.conf - they are prefixed 'crypto_' - totem_options -- options of section 'totem' in corosync.conf - quorum_options -- options of section 'quorum' in corosync.conf - wait -- specifies if command should try to wait for cluster to start up. - Has no effect start is False. If set to False command will not wait for - cluster to start. If None command will wait for some default timeout. - If int wait set timeout to int value of seconds. - start -- if True start cluster when it is set up - enable -- if True enable cluster when it is set up - no_keys_sync -- if True do not create and distribute files: pcsd ssl - cert and key, pacemaker authkey, corosync authkey - no_cluster_uuid -- if True, do not generate a unique cluster UUID into - the 'totem' section of corosync.conf - force_flags -- list of flags codes - - The command is defaulting node addresses if they are not specified. The - defaulting is done for each node individually if and only if the "addrs" key - is not present for the node. If the "addrs" key is present and holds an - empty list, no defaulting is done. - This will default addresses for node2 and won't modify addresses for other - nodes (no addresses will be defined for node3): - nodes=[ - {"name": "node1", "addrs": ["node1-addr"]}, - {"name": "node2"}, - {"name": "node3", "addrs": []}, - ] - """ - _ensure_live_env(env) # raises if env is not live - force = report_codes.FORCE in force_flags - - transport_type = transport_type or "knet" - transport_options = transport_options or {} - link_list = link_list or [] - compression_options = compression_options or {} - crypto_options = crypto_options or {} - totem_options = totem_options or {} - quorum_options = quorum_options or {} - nodes = [_normalize_dict(node, {"addrs"}) for node in nodes] - if ( - transport_type in corosync_constants.TRANSPORTS_KNET - and not crypto_options - ): - crypto_options = { - "cipher": "aes256", - "hash": "sha256", - } - - report_processor = env.report_processor - target_factory = env.get_node_target_factory() - - # Get targets for all nodes and report unknown (== not-authorized) nodes. - # If a node doesn't contain the 'name' key, validation of inputs reports it. - # That means we don't report missing names but cannot rely on them being - # present either. - ( - target_report_list, - target_list, - ) = target_factory.get_target_list_with_reports( - [node["name"] for node in nodes if "name" in node], - allow_skip=False, - ) - report_processor.report_list(target_report_list) - - # Use an address defined in known-hosts for each node with no addresses - # specified. This allows users not to specify node addresses at all which - # simplifies the whole cluster setup command / form significantly. - addrs_defaulter = _get_addrs_defaulter( - report_processor, {target.label: target for target in target_list} - ) - nodes = [ - _set_defaults_in_dict(node, {"addrs": addrs_defaulter}) - for node in nodes - ] - - # Validate inputs. - report_processor.report_list( - _validate_create_corosync_conf( - cluster_name, - nodes, - transport_type, - transport_options, - link_list, - compression_options, - crypto_options, - totem_options, - quorum_options, - force, - ) - ) - - # Validate flags - wait_timeout = _get_validated_wait_timeout(report_processor, wait, start) - - # Validate the nodes - com_cmd: AllSameDataMixin = GetHostInfo(report_processor) - com_cmd.set_targets(target_list) - report_processor.report_list( - _host_check_cluster_setup( - run_com(env.get_node_communicator(), com_cmd), force - ) - ) - - # If there is an error reading the file, this will report it and exit - # safely before any change is made to the nodes. - sync_ssl_certs = _is_ssl_cert_sync_enabled(report_processor) - - if report_processor.has_errors: - raise LibraryError() - - # Validation done. If errors occurred, an exception has been raised and we - # don't get below this line. - - # Destroy cluster on all nodes. - com_cmd = cluster.Destroy(env.report_processor) - com_cmd.set_targets(target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # Distribute auth tokens. - com_cmd = UpdateKnownHosts( - env.report_processor, - known_hosts_to_add=env.get_known_hosts( - [target.label for target in target_list] - ), - known_hosts_to_remove=[], - ) - com_cmd.set_targets(target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # TODO This should be in the file distribution call but so far we don't - # have a call which allows to save and delete files at the same time. - com_cmd = RemoveFilesWithoutForces( - env.report_processor, - {"pcsd settings": {"type": "pcsd_settings"}}, - ) - com_cmd.set_targets(target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - if not no_keys_sync: - # Distribute configuration files except corosync.conf. Sending - # corosync.conf serves as a "commit" as its presence on a node marks the - # node as a part of a cluster. - corosync_authkey = generate_binary_key( - random_bytes_count=settings.corosync_authkey_bytes - ) - pcmk_authkey = generate_binary_key( - random_bytes_count=settings.pacemaker_authkey_bytes - ) - actions = {} - actions.update( - node_communication_format.corosync_authkey_file(corosync_authkey) - ) - actions.update( - node_communication_format.pcmk_authkey_file(pcmk_authkey) - ) - com_cmd = DistributeFilesWithoutForces(env.report_processor, actions) - com_cmd.set_targets(target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # Distribute and reload pcsd SSL certificate - if sync_ssl_certs: - report_processor.report( - ReportItem.info( - reports.messages.PcsdSslCertAndKeyDistributionStarted( - sorted([target.label for target in target_list]) - ) - ) - ) - # Local certificate and key cannot be used because the local node - # may not be a part of the new cluter at all. - ssl_key_raw = ssl.generate_key() - ssl_key = ssl.dump_key(ssl_key_raw) - ssl_cert = ssl.dump_cert( - ssl.generate_cert(ssl_key_raw, target_list[0].label) - ) - com_cmd = SendPcsdSslCertAndKey( - env.report_processor, ssl_cert, ssl_key - ) - com_cmd.set_targets(target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # Create and distribute corosync.conf. Once a node saves corosync.conf it - # is considered to be in a cluster. - # raises if corosync not valid - com_cmd = DistributeFilesWithoutForces( - env.report_processor, - node_communication_format.corosync_conf_file( - _create_corosync_conf( - cluster_name, - nodes, - transport_type, - transport_options, - link_list, - compression_options, - crypto_options, - totem_options, - quorum_options, - no_cluster_uuid, - ).config.export() - ), - ) - com_cmd.set_targets(target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - if env.report_processor.report( - ReportItem.info(reports.messages.ClusterSetupSuccess()) - ).has_errors: - raise LibraryError() - - # Optionally enable and start cluster services. - if enable: - com_cmd = EnableCluster(env.report_processor) - com_cmd.set_targets(target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - if start: - _start_cluster( - env.communicator_factory, - env.report_processor, - target_list, - wait_timeout=wait_timeout, - ) - - -def setup_local( - env: LibraryEnvironment, - cluster_name: str, - nodes: Sequence[Mapping[str, Any]], - transport_type: Optional[str], - transport_options: Mapping[str, str], - link_list: Sequence[Mapping[str, Any]], - compression_options: Mapping[str, str], - crypto_options: Mapping[str, str], - totem_options: Mapping[str, str], - quorum_options: Mapping[str, str], - no_cluster_uuid: bool = False, - force_flags: Collection[reports.types.ForceCode] = (), -) -> bytes: - """ - Return corosync.conf text based on specified parameters. - Raise LibraryError on any error. - - env - cluster_name -- name of a cluster to set up - nodes list -- list of dicts which represents node. - Supported keys are: name (required), addrs. See note bellow. - transport_type -- transport type of a cluster - transport_options -- transport specific options - link_list -- list of links, depends of transport_type - compression_options -- only available for knet transport. In - corosync.conf they are prefixed 'knet_compression_' - crypto_options -- only available for knet transport'. In corosync.conf - they are prefixed 'crypto_' - totem_options -- options of section 'totem' in corosync.conf - quorum_options -- options of section 'quorum' in corosync.conf - no_cluster_uuid -- if True, do not generate a unique cluster UUID into - the totem section of corosync.conf - force_flags -- list of flags codes - - The command is defaulting node addresses if they are not specified. The - defaulting is done for each node individually if and only if the "addrs" key - is not present for the node. If the "addrs" key is present and holds an - empty list, no defaulting is done. - This will default addresses for node2 and won't modify addresses for other - nodes (no addresses will be defined for node3): - nodes=[ - {"name": "node1", "addrs": ["node1-addr"]}, - {"name": "node2"}, - {"name": "node3", "addrs": []}, - ] - """ - # pylint: disable=too-many-arguments - # pylint: disable=too-many-locals - force = report_codes.FORCE in force_flags - - transport_type = transport_type or "knet" - nodes = [_normalize_dict(node, {"addrs"}) for node in nodes] - if ( - transport_type in corosync_constants.TRANSPORTS_KNET - and not crypto_options - ): - crypto_options = { - "cipher": "aes256", - "hash": "sha256", - } - - report_processor = env.report_processor - target_factory = env.get_node_target_factory() - - # Get targets just for address defaulting, no need to report unknown nodes - _, target_list = target_factory.get_target_list_with_reports( - [node["name"] for node in nodes if "name" in node], - allow_skip=False, - ) - - # Use an address defined in known-hosts for each node with no addresses - # specified. This allows users not to specify node addresses at all which - # simplifies the whole cluster setup command / form significantly. - - # If there is no address for a node in known-hosts, use its name as the - # default address - addrs_defaulter = _get_addrs_defaulter( - report_processor, - {target.label: target for target in target_list}, - default_to_name_if_no_target=True, - ) - nodes = [ - _set_defaults_in_dict(node, {"addrs": addrs_defaulter}) - for node in nodes - ] - - # Validate inputs. - if report_processor.report_list( - _validate_create_corosync_conf( - cluster_name, - nodes, - transport_type, - transport_options, - link_list, - compression_options, - crypto_options, - totem_options, - quorum_options, - force, - ) - ).has_errors: - raise LibraryError() - - # Validation done. If errors occurred, an exception has been raised and we - # don't get below this line. - - return ( - _create_corosync_conf( - cluster_name, - nodes, - transport_type, - transport_options, - link_list, - compression_options, - crypto_options, - totem_options, - quorum_options, - no_cluster_uuid, - ) - .config.export() - .encode("utf-8") - ) - - -def _validate_create_corosync_conf( - cluster_name: str, - nodes: Sequence[Mapping[str, Any]], - transport_type: str, - transport_options: Mapping[str, str], - link_list: Sequence[Mapping[str, Any]], - compression_options: Mapping[str, str], - crypto_options: Mapping[str, str], - totem_options: Mapping[str, str], - quorum_options: Mapping[str, str], - force: bool, -) -> reports.ReportItemList: - # pylint: disable=too-many-arguments - - # Get IP version for node addresses validation. Defaults taken from man - # corosync.conf - ip_version = ( - corosync_constants.IP_VERSION_4 - if transport_type == "udp" - else corosync_constants.IP_VERSION_64 - ) - if ( - transport_options.get("ip_version") - in corosync_constants.IP_VERSION_VALUES - ): - ip_version = transport_options["ip_version"] - - report_list = [] - report_list += config_validators.create( - cluster_name, - nodes, - transport_type, - ip_version, - force_unresolvable=force, - force_cluster_name=force, - ) - max_node_addr_count = max((len(node["addrs"]) for node in nodes), default=0) - if transport_type in corosync_constants.TRANSPORTS_KNET: - report_list += config_validators.create_transport_knet( - transport_options, compression_options, crypto_options - ) - report_list += config_validators.create_link_list_knet( - link_list, max_node_addr_count - ) - - elif transport_type in corosync_constants.TRANSPORTS_UDP: - report_list += config_validators.create_transport_udp( - transport_options, compression_options, crypto_options - ) - report_list += config_validators.create_link_list_udp( - link_list, max_node_addr_count - ) - return ( - report_list - + config_validators.create_totem(totem_options) - # We are creating the config and we know there is no qdevice in it. - + config_validators.create_quorum_options(quorum_options, False) - ) - - -def _create_corosync_conf( - cluster_name: str, - nodes: Sequence[Mapping[str, Any]], - transport_type: str, - transport_options: Mapping[str, str], - link_list: Sequence[Mapping[str, Any]], - compression_options: Mapping[str, str], - crypto_options: Mapping[str, str], - totem_options: Mapping[str, str], - quorum_options: Mapping[str, str], - no_cluster_uuid: bool, -) -> config_facade.ConfigFacade: - # pylint: disable=too-many-arguments - corosync_conf = config_facade.ConfigFacade.create( - cluster_name, nodes, transport_type - ) - corosync_conf.set_totem_options(totem_options) - corosync_conf.set_quorum_options(quorum_options) - corosync_conf.create_link_list(link_list) - corosync_conf.set_transport_options( - transport_options, - compression_options, - crypto_options, - ) - if not no_cluster_uuid: - corosync_conf.set_cluster_uuid(generate_uuid()) - - _verify_corosync_conf(corosync_conf) # raises if corosync not valid - return corosync_conf - - -def _config_update( - report_processor: ReportProcessor, - corosync_conf: config_facade.ConfigFacade, - transport_options: Mapping[str, str], - compression_options: Mapping[str, str], - crypto_options: Mapping[str, str], - totem_options: Mapping[str, str], -) -> None: - transport_type = corosync_conf.get_transport() - report_list = config_validators.update_totem(totem_options) - if transport_type in corosync_constants.TRANSPORTS_KNET: - report_list += config_validators.update_transport_knet( - transport_options, - compression_options, - crypto_options, - corosync_conf.get_crypto_options(), - ) - elif transport_type in corosync_constants.TRANSPORTS_UDP: - report_list += config_validators.update_transport_udp( - transport_options, - compression_options, - crypto_options, - ) - else: - report_processor.report( - ReportItem.error( - reports.messages.CorosyncConfigUnsupportedTransport( - transport_type, sorted(corosync_constants.TRANSPORTS_ALL) - ) - ) - ) - if report_processor.report_list(report_list).has_errors: - raise LibraryError() - - corosync_conf.set_totem_options(totem_options) - corosync_conf.set_transport_options( - transport_options, - compression_options, - crypto_options, - ) - _verify_corosync_conf(corosync_conf) # raises if corosync not valid - - -def config_update( - env: LibraryEnvironment, - transport_options: Mapping[str, str], - compression_options: Mapping[str, str], - crypto_options: Mapping[str, str], - totem_options: Mapping[str, str], -) -> None: - """ - Update corosync.conf in the local cluster - - env - transport_options -- transport specific options - compression_options -- only available for knet transport. In - corosync.conf they are prefixed 'knet_compression_' - crypto_options -- only available for knet transport. In corosync.conf - they are prefixed 'crypto_' - totem_options -- options of section 'totem' in corosync.conf - """ - _ensure_live_env(env) - corosync_conf = env.get_corosync_conf() - _config_update( - env.report_processor, - corosync_conf, - transport_options, - compression_options, - crypto_options, - totem_options, - ) - env.push_corosync_conf(corosync_conf) - - -def config_update_local( - env: LibraryEnvironment, - corosync_conf_content: bytes, - transport_options: Mapping[str, str], - compression_options: Mapping[str, str], - crypto_options: Mapping[str, str], - totem_options: Mapping[str, str], -) -> bytes: - """ - Update corosync.conf passed as an argument and return the updated conf - - env - corosync_conf_content -- corosync.conf to be updated - transport_options -- transport specific options - compression_options -- only available for knet transport. In - corosync.conf they are prefixed 'knet_compression_' - crypto_options -- only available for knet transport. In corosync.conf - they are prefixed 'crypto_' - totem_options -- options of section 'totem' in corosync.conf - """ - # As we are getting a corosync.conf content as an argument, we want to make - # sure it was not given to LibraryEnvironment as well. Also we don't - # allow/need CIB to be handled by LibraryEnvironment. - _ensure_live_env(env) - corosync_conf_instance = FileInstance.for_corosync_conf() - try: - corosync_conf: config_facade.ConfigFacade = cast( - config_facade.ConfigFacade, - corosync_conf_instance.raw_to_facade(corosync_conf_content), - ) - except ParserErrorException as e: - if env.report_processor.report_list( - corosync_conf_instance.toolbox.parser.exception_to_report_list( - e, - corosync_conf_instance.toolbox.file_type_code, - None, - force_code=None, - is_forced_or_warning=False, - ) - ).has_errors: - raise LibraryError() from e - _config_update( - env.report_processor, - corosync_conf, - transport_options, - compression_options, - crypto_options, - totem_options, - ) - return corosync_conf_instance.facade_to_raw(corosync_conf) - - -def get_corosync_conf_struct(env: LibraryEnvironment) -> CorosyncConfDto: - """ - Read corosync.conf from the local node and return it in a structured form - """ - corosync_conf = env.get_corosync_conf() - quorum_device_dto: Optional[CorosyncQuorumDeviceSettingsDto] = None - qd_model = corosync_conf.get_quorum_device_model() - if qd_model is not None: - ( - qd_model_options, - qd_generic_options, - qd_heuristics_options, - ) = corosync_conf.get_quorum_device_settings() - quorum_device_dto = CorosyncQuorumDeviceSettingsDto( - model=qd_model, - model_options=qd_model_options, - generic_options=qd_generic_options, - heuristics_options=qd_heuristics_options, - ) - try: - return CorosyncConfDto( - cluster_name=corosync_conf.get_cluster_name(), - cluster_uuid=corosync_conf.get_cluster_uuid(), - transport=CorosyncTransportType.from_str( - corosync_conf.get_transport() - ), - totem_options=corosync_conf.get_totem_options(), - transport_options=corosync_conf.get_transport_options(), - compression_options=corosync_conf.get_compression_options(), - crypto_options=corosync_conf.get_crypto_options(), - nodes=[node.to_dto() for node in corosync_conf.get_nodes()], - links_options=corosync_conf.get_links_options(), - quorum_options=corosync_conf.get_quorum_options(), - quorum_device=quorum_device_dto, - ) - except UnknownCorosyncTransportTypeException as e: - raise LibraryError( - ReportItem.error( - reports.messages.CorosyncConfigUnsupportedTransport( - e.transport, sorted(corosync_constants.TRANSPORTS_ALL) - ) - ) - ) from e - - -def add_nodes( - env: LibraryEnvironment, - nodes, - wait=False, - start=False, - enable=False, - no_watchdog_validation=False, - force_flags: Collection[reports.types.ForceCode] = (), -): - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - # pylint: disable=too-many-statements - """ - Add specified nodes to the local cluster - Raise LibraryError on any error. - - env LibraryEnvironment - nodes list -- list of dicts which represents node. - Supported keys are: name (required), addrs (list), devices (list), - watchdog. See note below. - wait -- specifies if command should try to wait for cluster to start up. - Has no effect start is False. If set to False command will not wait for - cluster to start. If None command will wait for some default timeout. - If int wait set timeout to int value of seconds. - start bool -- if True start cluster when it is set up - enable bool -- if True enable cluster when it is set up - no_watchdog_validation bool -- if True do not validate specified watchdogs - on remote hosts - force_flags list -- list of flags codes - - The command is defaulting node addresses if they are not specified. The - defaulting is done for each node individually if and only if the "addrs" key - is not present for the node. If the "addrs" key is present and holds an - empty list, no defaulting is done. - This will default addresses for node2 and won't modify addresses for other - nodes (no addresses will be defined for node3): - nodes=[ - {"name": "node1", "addrs": ["node1-addr"]}, - {"name": "node2"}, - {"name": "node3", "addrs": []}, - ] - """ - _ensure_live_env(env) # raises if env is not live - - force = report_codes.FORCE in force_flags - skip_offline_nodes = report_codes.SKIP_OFFLINE_NODES in force_flags - - report_processor = env.report_processor - target_factory = env.get_node_target_factory() - is_sbd_enabled = sbd.is_sbd_enabled(env.service_manager) - corosync_conf = env.get_corosync_conf() - corosync_node_options = {"name", "addrs"} - sbd_node_options = {"devices", "watchdog"} - - keys_to_normalize = {"addrs"} - if is_sbd_enabled: - keys_to_normalize |= sbd_node_options - new_nodes = [_normalize_dict(node, keys_to_normalize) for node in nodes] - - # get targets for existing nodes - cluster_nodes_names, nodes_report_list = get_existing_nodes_names( - corosync_conf, - # Pcs is unable to communicate with nodes missing names. It cannot send - # new corosync.conf to them. That might break the cluster. Hence we - # error out. - error_on_missing_name=True, - ) - report_processor.report_list(nodes_report_list) - ( - target_report_list, - cluster_nodes_target_list, - ) = target_factory.get_target_list_with_reports( - cluster_nodes_names, - skip_non_existing=skip_offline_nodes, - ) - report_processor.report_list(target_report_list) - - # get a target for qnetd if needed - if corosync_conf.get_quorum_device_model() == "net": - ( - qdevice_model_options, - _, - _, - ) = corosync_conf.get_quorum_device_settings() - try: - qnetd_target = target_factory.get_target( - qdevice_model_options["host"] - ) - except HostNotFound: - report_processor.report( - ReportItem.error( - reports.messages.HostNotFound( - [qdevice_model_options["host"]] - ) - ) - ) - - # Get targets for new nodes and report unknown (== not-authorized) nodes. - # If a node doesn't contain the 'name' key, validation of inputs reports it. - # That means we don't report missing names but cannot rely on them being - # present either. - ( - target_report_list, - new_nodes_target_list, - ) = target_factory.get_target_list_with_reports( - [node["name"] for node in new_nodes if "name" in node], - allow_skip=False, - ) - report_processor.report_list(target_report_list) - - # Set default values for not-specified node options. - # Use an address defined in known-hosts for each node with no addresses - # specified. This allows users not to specify node addresses at all which - # simplifies the whole node add command / form significantly. - new_nodes_target_dict = { - target.label: target for target in new_nodes_target_list - } - addrs_defaulter = _get_addrs_defaulter( - report_processor, new_nodes_target_dict - ) - new_nodes_defaulters = {"addrs": addrs_defaulter} - if is_sbd_enabled: - watchdog_defaulter = _get_watchdog_defaulter( - report_processor, new_nodes_target_dict - ) - new_nodes_defaulters["devices"] = lambda _: [] - new_nodes_defaulters["watchdog"] = watchdog_defaulter - new_nodes = [ - _set_defaults_in_dict(node, new_nodes_defaulters) for node in new_nodes - ] - new_nodes_dict = { - node["name"]: node for node in new_nodes if "name" in node - } - - # Validate inputs - node options names - # We do not want to make corosync validators know about SBD options and - # vice versa. Therefore corosync and SBD validators get only valid corosync - # and SBD options respectively, and we need to check for any surplus - # options here. - report_processor.report_list( - validate.NamesIn( - corosync_node_options | sbd_node_options, option_type="node" - ).validate( - { - # Get a dict containing options of all nodes. Values don't - # matter for validate.NamesIn validator. - option_name: "" - for node_option_names in [node.keys() for node in new_nodes] - for option_name in node_option_names - } - ) - ) - - # Validate inputs - corosync part - try: - cib = env.get_cib() - cib_nodes = get_remote_nodes(cib) + get_guest_nodes(cib) - except LibraryError: - cib_nodes = [] - report_processor.report( - ReportItem( - reports.item.get_severity(report_codes.FORCE, force), - reports.messages.CibLoadErrorGetNodesForValidation(), - ) - ) - # corosync validator rejects non-corosync keys - new_nodes_corosync = [ - {key: node[key] for key in corosync_node_options if key in node} - for node in new_nodes - ] - report_processor.report_list( - config_validators.add_nodes( - new_nodes_corosync, - corosync_conf.get_nodes(), - cib_nodes, - force_unresolvable=force, - ) - ) - - # Validate inputs - SBD part - if is_sbd_enabled: - report_processor.report_list( - sbd.validate_new_nodes_devices( - { - node["name"]: node["devices"] - for node in new_nodes - if "name" in node - } - ) - ) - else: - for node in new_nodes: - sbd_options = sbd_node_options.intersection(node.keys()) - if sbd_options and "name" in node: - report_processor.report( - ReportItem.error( - reports.messages.SbdNotUsedCannotSetSbdOptions( - sorted(sbd_options), node["name"] - ) - ) - ) - - # Validate inputs - flags part - wait_timeout = _get_validated_wait_timeout(report_processor, wait, start) - - # Get online cluster nodes - # This is the only call in which we accept skip_offline_nodes option for the - # cluster nodes. In all the other actions we communicate only with the - # online nodes. This allows us to simplify code as any communication issue - # is considered an error, ends the command processing and is not possible - # to skip it by skip_offline_nodes. We do not have to care about a situation - # when a communication command cannot connect to some nodes and then the - # next command can connect but fails due to the previous one did not - # succeed. - online_cluster_target_list = [] - if cluster_nodes_target_list: - com_cmd: AllSameDataMixin = GetOnlineTargets( - report_processor, - ignore_offline_targets=skip_offline_nodes, - ) - com_cmd.set_targets(cluster_nodes_target_list) - online_cluster_target_list = run_com( - env.get_node_communicator(), com_cmd - ) - offline_cluster_target_list = [ - target - for target in cluster_nodes_target_list - if target not in online_cluster_target_list - ] - if not online_cluster_target_list: - report_processor.report( - ReportItem.error( - reports.messages.UnableToPerformOperationOnAnyNode() - ) - ) - elif offline_cluster_target_list and skip_offline_nodes: - # TODO: report (warn) how to fix offline nodes when they come online - # report_processor.report(None) - pass - - # Validate existing cluster nodes status - atb_has_to_be_enabled = sbd.atb_has_to_be_enabled( - env.service_manager, corosync_conf, len(new_nodes) - ) - if atb_has_to_be_enabled: - if online_cluster_target_list: - com_cmd = CheckCorosyncOffline( - report_processor, allow_skip_offline=False - ) - com_cmd.set_targets(online_cluster_target_list) - cluster_running_target_list = run_com( - env.get_node_communicator(), com_cmd - ) - if cluster_running_target_list: - report_processor.report( - ReportItem.error( - reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbdClusterIsRunning() - ) - ) - else: - report_processor.report( - ReportItem.warning( - reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbd() - ) - ) - - # Validate new nodes. All new nodes have to be online. - com_cmd = GetHostInfo(report_processor) - com_cmd.set_targets(new_nodes_target_list) - report_processor.report_list( - _host_check_cluster_setup( - run_com(env.get_node_communicator(), com_cmd), - force, - # version of services may not be the same across the existing - # cluster nodes, so it's not easy to make this check properly - check_services_versions=False, - ) - ) - - # Validate SBD on new nodes - if is_sbd_enabled: - if no_watchdog_validation: - report_processor.report( - ReportItem.warning( - reports.messages.SbdWatchdogValidationInactive() - ) - ) - com_cmd_sbd = CheckSbd(report_processor) - for new_node_target in new_nodes_target_list: - new_node = new_nodes_dict[new_node_target.label] - # Do not send watchdog if validation is turned off. Listing of - # available watchdogs in pcsd may restart the machine in some - # corner cases. - com_cmd_sbd.add_request( - new_node_target, - watchdog="" if no_watchdog_validation else new_node["watchdog"], - device_list=new_node["devices"], - ) - run_com(env.get_node_communicator(), com_cmd_sbd) - - # If there is an error reading the file, this will report it and exit - # safely before any change is made to the nodes. - sync_ssl_certs = _is_ssl_cert_sync_enabled(report_processor) - - if report_processor.has_errors: - raise LibraryError() - - # Validation done. If errors occurred, an exception has been raised and we - # don't get below this line. - - # First set up everything else than corosync. Once the new nodes are present - # in corosync.conf, they're considered part of a cluster and the node add - # command cannot be run again. So we need to minimize the amount of actions - # (and therefore possible failures) after adding the nodes to corosync. - - # distribute auth tokens of all cluster nodes (including the new ones) to - # all new nodes - com_cmd = UpdateKnownHosts( - env.report_processor, - known_hosts_to_add=env.get_known_hosts( - cluster_nodes_names + list(new_nodes_dict.keys()) - ), - known_hosts_to_remove=[], - ) - com_cmd.set_targets(new_nodes_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # qdevice setup - if corosync_conf.get_quorum_device_model() == "net": - qdevice_net.set_up_client_certificates( - env.cmd_runner(), - env.report_processor, - env.communicator_factory, - qnetd_target, - corosync_conf.get_cluster_name(), - new_nodes_target_list, - # we don't want to allow skipping offline nodes which are being - # added, otherwise qdevice will not work properly - skip_offline_nodes=False, - allow_skip_offline=False, - ) - - # sbd setup - if is_sbd_enabled: - sbd_cfg = environment_file_to_dict(sbd.get_local_sbd_config()) - - com_cmd_sbd_cfg = SetSbdConfig(env.report_processor) - for new_node_target in new_nodes_target_list: - new_node = new_nodes_dict[new_node_target.label] - com_cmd_sbd_cfg.add_request( - new_node_target, - sbd.create_sbd_config( - sbd_cfg, - new_node["name"], - watchdog=new_node["watchdog"], - device_list=new_node["devices"], - ), - ) - run_and_raise(env.get_node_communicator(), com_cmd_sbd_cfg) - - com_cmd = EnableSbdService(env.report_processor) - com_cmd.set_targets(new_nodes_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - else: - com_cmd = DisableSbdService(env.report_processor) - com_cmd.set_targets(new_nodes_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # booth setup - booth_sync.send_all_config_to_node( - env.get_node_communicator(), - env.report_processor, - new_nodes_target_list, - rewrite_existing=force, - skip_wrong_config=force, - ) - - # distribute corosync and pacemaker authkeys and other config files - files_action = {} - severity = reports.item.get_severity(reports.codes.FORCE, force) - if os.path.isfile(settings.corosync_authkey_file): - try: - with open( - settings.corosync_authkey_file, "rb" - ) as corosync_authkey_file: - files_action.update( - node_communication_format.corosync_authkey_file( - corosync_authkey_file.read() - ) - ) - except OSError as e: - report_processor.report( - ReportItem( - severity, - reports.messages.FileIoError( - file_type_codes.COROSYNC_AUTHKEY, - RawFileError.ACTION_READ, - format_os_error(e), - file_path=settings.corosync_authkey_file, - ), - ) - ) - - if os.path.isfile(settings.pacemaker_authkey_file): - try: - with open( - settings.pacemaker_authkey_file, "rb" - ) as pcmk_authkey_file: - files_action.update( - node_communication_format.pcmk_authkey_file( - pcmk_authkey_file.read() - ) - ) - except OSError as e: - report_processor.report( - ReportItem( - severity, - reports.messages.FileIoError( - file_type_codes.PACEMAKER_AUTHKEY, - RawFileError.ACTION_READ, - format_os_error(e), - file_path=settings.pacemaker_authkey_file, - ), - ) - ) - - if os.path.isfile(settings.pcsd_dr_config_location): - try: - with open( - settings.pcsd_dr_config_location, "rb" - ) as pcs_dr_config_file: - files_action.update( - node_communication_format.pcs_dr_config_file( - pcs_dr_config_file.read() - ) - ) - except OSError as e: - report_processor.report( - ReportItem( - severity, - reports.messages.FileIoError( - file_type_codes.PCS_DR_CONFIG, - RawFileError.ACTION_READ, - format_os_error(e), - file_path=settings.pcsd_dr_config_location, - ), - ) - ) - - # pcs_settings.conf was previously synced using pcsdcli send_local_configs. - # This has been changed temporarily until new system for distribution and - # synchronization of configs will be introduced. - if os.path.isfile(settings.pcsd_settings_conf_location): - try: - with open( - settings.pcsd_settings_conf_location, "r" - ) as pcs_settings_conf_file: - files_action.update( - node_communication_format.pcs_settings_conf_file( - pcs_settings_conf_file.read() - ) - ) - except OSError as e: - report_processor.report( - ReportItem( - severity, - reports.messages.FileIoError( - file_type_codes.PCS_SETTINGS_CONF, - RawFileError.ACTION_READ, - format_os_error(e), - file_path=settings.pcsd_settings_conf_location, - ), - ) - ) - - # stop here if one of the files could not be loaded and it was not forced - if report_processor.has_errors: - raise LibraryError() - - if files_action: - com_cmd = DistributeFilesWithoutForces( - env.report_processor, files_action - ) - com_cmd.set_targets(new_nodes_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # Distribute and reload pcsd SSL certificate - if sync_ssl_certs: - report_processor.report( - ReportItem.info( - reports.messages.PcsdSslCertAndKeyDistributionStarted( - sorted([target.label for target in new_nodes_target_list]) - ) - ) - ) - - try: - with open(settings.pcsd_cert_location, "r") as file: - ssl_cert = file.read() - except OSError as e: - report_processor.report( - ReportItem.error( - reports.messages.FileIoError( - file_type_codes.PCSD_SSL_CERT, - RawFileError.ACTION_READ, - format_os_error(e), - file_path=settings.pcsd_cert_location, - ) - ) - ) - try: - with open(settings.pcsd_key_location, "r") as file: - ssl_key = file.read() - except OSError as e: - report_processor.report( - ReportItem.error( - reports.messages.FileIoError( - file_type_codes.PCSD_SSL_KEY, - RawFileError.ACTION_READ, - format_os_error(e), - file_path=settings.pcsd_key_location, - ) - ) - ) - if report_processor.has_errors: - raise LibraryError() - - com_cmd = SendPcsdSslCertAndKey(env.report_processor, ssl_cert, ssl_key) - com_cmd.set_targets(new_nodes_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # When corosync >= 2 is in use, the procedure for adding a node is: - # 1. add the new node to corosync.conf on all existing nodes - # 2. reload corosync.conf before the new node is started - # 3. start the new node - # If done otherwise, membership gets broken and qdevice hangs. Cluster - # will recover after a minute or so but still it's a wrong way. - - corosync_conf.add_nodes(new_nodes_corosync) - if atb_has_to_be_enabled: - corosync_conf.set_quorum_options(dict(auto_tie_breaker="1")) - - _verify_corosync_conf(corosync_conf) # raises if corosync not valid - com_cmd = DistributeCorosyncConf( - env.report_processor, - corosync_conf.config.export(), - allow_skip_offline=False, - ) - com_cmd.set_targets(online_cluster_target_list + new_nodes_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - com_cmd = ReloadCorosyncConf(env.report_processor) - com_cmd.set_targets(online_cluster_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # Optionally enable and start cluster services. - if enable: - com_cmd = EnableCluster(env.report_processor) - com_cmd.set_targets(new_nodes_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - if start: - _start_cluster( - env.communicator_factory, - env.report_processor, - new_nodes_target_list, - wait_timeout=wait_timeout, - ) - - -def _ensure_live_env(env: LibraryEnvironment): - not_live = [] - if not env.is_cib_live: - not_live.append(file_type_codes.CIB) - if not env.is_corosync_conf_live: - not_live.append(file_type_codes.COROSYNC_CONF) - if not_live: - raise LibraryError( - ReportItem.error(reports.messages.LiveEnvironmentRequired(not_live)) - ) - - -def _start_cluster( - communicator_factory, - report_processor: ReportProcessor, - target_list, - wait_timeout=False, -): - # Large clusters take longer time to start up. So we make the timeout - # longer for each 8 nodes: - # 1 - 8 nodes: 1 * timeout - # 9 - 16 nodes: 2 * timeout - # 17 - 24 nodes: 3 * timeout - # and so on ... - # Users can override this and set their own timeout by specifying - # the --request-timeout option. - timeout = int( - settings.default_request_timeout * math.ceil(len(target_list) / 8.0) - ) - com_cmd = StartCluster(report_processor) - com_cmd.set_targets(target_list) - run_and_raise( - communicator_factory.get_communicator(request_timeout=timeout), com_cmd - ) - if wait_timeout is not False: - if report_processor.report_list( - _wait_for_pacemaker_to_start( - communicator_factory.get_communicator(), - report_processor, - target_list, - # wait_timeout is either None or a timeout - timeout=wait_timeout, - ) - ).has_errors: - raise LibraryError() - - -def _wait_for_pacemaker_to_start( - node_communicator, - report_processor: ReportProcessor, - target_list, - timeout=None, -): - timeout = 60 * 15 if timeout is None else timeout - interval = 2 - stop_at = time.time() + timeout - report_processor.report( - ReportItem.info( - reports.messages.WaitForNodeStartupStarted( - sorted([target.label for target in target_list]) - ) - ) - ) - error_report_list = [] - has_errors = False - while target_list: - if time.time() > stop_at: - error_report_list.append( - ReportItem.error(reports.messages.WaitForNodeStartupTimedOut()) - ) - break - time.sleep(interval) - com_cmd = CheckPacemakerStarted(report_processor) - com_cmd.set_targets(target_list) - target_list = run_com(node_communicator, com_cmd) - has_errors = has_errors or com_cmd.has_errors - - if error_report_list or has_errors: - error_report_list.append( - ReportItem.error(reports.messages.WaitForNodeStartupError()) - ) - return error_report_list - - -def _host_check_cluster_setup( - host_info_dict, force, check_services_versions=True -): - # pylint: disable=too-many-locals - report_list = [] - # We only care about services which matter for creating a cluster. It does - # not make sense to check e.g. booth when a) it will never be used b) it - # will be used in a year - which means we should do the check in a year. - service_version_dict = { - "pacemaker": {}, - "corosync": {}, - "pcsd": {}, - } - required_service_list = ["pacemaker", "corosync"] - required_as_stopped_service_list = required_service_list + [ - "pacemaker_remote" - ] - severity = reports.item.get_severity(report_codes.FORCE, force) - cluster_exists_on_nodes = False - for host_name, host_info in host_info_dict.items(): - try: - services = host_info["services"] - if check_services_versions: - for service, version_dict in service_version_dict.items(): - version_dict[host_name] = services[service]["version"] - missing_service_list = [ - service - for service in required_service_list - if not services[service]["installed"] - ] - if missing_service_list: - report_list.append( - ReportItem.error( - reports.messages.ServiceNotInstalled( - host_name, sorted(missing_service_list) - ) - ) - ) - cannot_be_running_service_list = [ - service - for service in required_as_stopped_service_list - if service in services and services[service]["running"] - ] - if cannot_be_running_service_list: - cluster_exists_on_nodes = True - report_list.append( - ReportItem( - severity=severity, - message=reports.messages.HostAlreadyInClusterServices( - host_name, - sorted(cannot_be_running_service_list), - ), - ) - ) - if host_info["cluster_configuration_exists"]: - cluster_exists_on_nodes = True - report_list.append( - ReportItem( - severity=severity, - message=reports.messages.HostAlreadyInClusterConfig( - host_name, - ), - ) - ) - except KeyError: - report_list.append( - ReportItem.error( - reports.messages.InvalidResponseFormat(host_name) - ) - ) - - if check_services_versions: - for service, version_dict in service_version_dict.items(): - report_list.extend( - _check_for_not_matching_service_versions(service, version_dict) - ) - - if cluster_exists_on_nodes and not force: - # This is always a forceable error - report_list.append( - ReportItem( - severity=reports.item.ReportItemSeverity.error( - report_codes.FORCE - ), - message=reports.messages.ClusterWillBeDestroyed(), - ) - ) - return report_list - - -def _check_for_not_matching_service_versions(service, service_version_dict): - if len(set(service_version_dict.values())) <= 1: - return [] - return [ - ReportItem.error( - reports.messages.ServiceVersionMismatch( - service, service_version_dict - ) - ) - ] - - -def _normalize_dict(input_dict, required_keys): - normalized = dict(input_dict) - for key in required_keys: - if key not in normalized: - normalized[key] = None - return normalized - - -def _set_defaults_in_dict(input_dict, defaults): - completed = dict(input_dict) - for key, factory in defaults.items(): - if completed[key] is None: - completed[key] = factory(input_dict) - return completed - - -def _get_addrs_defaulter( - report_processor: ReportProcessor, - targets_dict, - default_to_name_if_no_target: bool = False, -): - def defaulter(node): - if "name" not in node: - return [] - address_for_use = None - target = targets_dict.get(node["name"]) - if target: - address_for_use = target.first_addr - address_source = reports.const.DEFAULT_ADDRESS_SOURCE_KNOWN_HOSTS - elif default_to_name_if_no_target: - address_for_use = node["name"] - address_source = reports.const.DEFAULT_ADDRESS_SOURCE_HOST_NAME - if address_for_use: - report_processor.report( - ReportItem.info( - reports.messages.UsingDefaultAddressForHost( - node["name"], address_for_use, address_source - ) - ) - ) - return [address_for_use] - return [] - - return defaulter - - -def _get_watchdog_defaulter(report_processor: ReportProcessor, targets_dict): - del targets_dict - - def defaulter(node): - report_processor.report( - ReportItem.info( - reports.messages.UsingDefaultWatchdog( - settings.sbd_watchdog_default, - node["name"], - ) - ) - ) - return settings.sbd_watchdog_default - - return defaulter - - -def _get_validated_wait_timeout(report_processor, wait, start): - try: - if wait is False: - return False - if not start: - report_processor.report( - ReportItem.error( - reports.messages.WaitForNodeStartupWithoutStart() - ) - ) - return get_valid_timeout_seconds(wait) - except LibraryError as e: - report_processor.report_list(e.args) - return None - - -def _is_ssl_cert_sync_enabled(report_processor: ReportProcessor): - try: - if os.path.isfile(settings.pcsd_config): - with open(settings.pcsd_config, "r") as cfg_file: - cfg = environment_file_to_dict(cfg_file.read()) - return ( - cfg.get("PCSD_SSL_CERT_SYNC_ENABLED", "false").lower() - == "true" - ) - except OSError as e: - report_processor.report( - ReportItem.error( - reports.messages.FileIoError( - file_type_codes.PCSD_ENVIRONMENT_CONFIG, - RawFileError.ACTION_READ, - format_os_error(e), - file_path=settings.pcsd_config, - ) - ) - ) - return False - - -def _verify_corosync_conf(corosync_conf_facade): - # This is done in pcs.lib.env.LibraryEnvironment.push_corosync_conf - # usually. But there are special cases here which use custom corosync.conf - # pushing so the check must be done individually. - ( - bad_sections, - bad_attr_names, - bad_attr_values, - ) = config_parser.verify_section(corosync_conf_facade.config) - if bad_sections or bad_attr_names or bad_attr_values: - raise LibraryError( - ReportItem.error( - reports.messages.CorosyncConfigCannotSaveInvalidNamesValues( - bad_sections, - bad_attr_names, - bad_attr_values, - ) - ) - ) - - -def remove_nodes( - env: LibraryEnvironment, - node_list, - force_flags: Collection[reports.types.ForceCode] = (), -): - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - # pylint: disable=too-many-statements - """ - Remove nodes from a cluster. - - env LibraryEnvironment - node_list iterable -- names of nodes to remove - force_flags list -- list of flags codes - """ - _ensure_live_env(env) # raises if env is not live - - force_quorum_loss = report_codes.FORCE in force_flags - skip_offline = report_codes.SKIP_OFFLINE_NODES in force_flags - - report_processor = env.report_processor - target_factory = env.get_node_target_factory() - corosync_conf = env.get_corosync_conf() - - # validations - - cluster_nodes_names, report_list = get_existing_nodes_names( - corosync_conf, - # Pcs is unable to communicate with nodes missing names. It cannot send - # new corosync.conf to them. That might break the cluster. Hence we - # error out. - error_on_missing_name=True, - ) - report_processor.report_list(report_list) - - report_processor.report_list( - config_validators.remove_nodes( - node_list, - corosync_conf.get_nodes(), - corosync_conf.get_quorum_device_model(), - corosync_conf.get_quorum_device_settings(), - ) - ) - if report_processor.has_errors: - # If there is an error, there is usually not much sense in doing other - # validations: - # - if there would be no node left in the cluster, it's pointless - # to check for quorum loss or if at least one remaining node is online - # - if only one node is being removed and it doesn't exist, it's again - # pointless to check for other issues - raise LibraryError() - - ( - target_report_list, - cluster_nodes_target_list, - ) = target_factory.get_target_list_with_reports( - cluster_nodes_names, - skip_non_existing=skip_offline, - ) - known_nodes = {target.label for target in cluster_nodes_target_list} - unknown_nodes = { - name for name in cluster_nodes_names if name not in known_nodes - } - report_processor.report_list(target_report_list) - - com_cmd: AllSameDataMixin = GetOnlineTargets( - report_processor, - ignore_offline_targets=skip_offline, - ) - com_cmd.set_targets(cluster_nodes_target_list) - online_target_list = run_com(env.get_node_communicator(), com_cmd) - offline_target_list = [ - target - for target in cluster_nodes_target_list - if target not in online_target_list - ] - staying_online_target_list = [ - target for target in online_target_list if target.label not in node_list - ] - targets_to_remove = [ - target - for target in cluster_nodes_target_list - if target.label in node_list - ] - if not staying_online_target_list: - report_processor.report( - ReportItem.error( - reports.messages.UnableToConnectToAnyRemainingNode() - ) - ) - # If no remaining node is online, there is no point in checking quorum - # loss or anything as we would just get errors. - raise LibraryError() - - if skip_offline: - staying_offline_nodes = [ - target.label - for target in offline_target_list - if target.label not in node_list - ] + [name for name in unknown_nodes if name not in node_list] - if staying_offline_nodes: - report_processor.report( - ReportItem.warning( - reports.messages.UnableToConnectToAllRemainingNodes( - sorted(staying_offline_nodes) - ) - ) - ) - - atb_has_to_be_enabled = sbd.atb_has_to_be_enabled( - env.service_manager, corosync_conf, -len(node_list) - ) - if atb_has_to_be_enabled: - com_cmd = CheckCorosyncOffline( - report_processor, allow_skip_offline=False - ) - com_cmd.set_targets(staying_online_target_list) - cluster_running_target_list = run_com( - env.get_node_communicator(), com_cmd - ) - if cluster_running_target_list: - report_processor.report( - ReportItem.error( - reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbdClusterIsRunning() - ) - ) - else: - report_processor.report( - ReportItem.warning( - reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbd() - ) - ) - else: - # Check if removing the nodes would cause quorum loss. We ask the nodes - # to be removed for their view of quorum. If they are all stopped or - # not in a quorate partition, their removal cannot cause quorum loss. - # That's why we ask them and not the remaining nodes. - # example: 5-node cluster, 3 online nodes, removing one online node, - # results in 4-node cluster with 2 online nodes => quorum lost - # Check quorum loss only if ATB does not need to be enabled. If it is - # required, cluster has to be turned off and therefore it loses quorum. - com_cmd = cluster.GetQuorumStatus(report_processor) - com_cmd.set_targets(targets_to_remove) - failures, quorum_status_facade = run_com( - env.get_node_communicator(), com_cmd - ) - if quorum_status_facade: - if quorum_status_facade.stopping_nodes_cause_quorum_loss(node_list): - report_processor.report( - ReportItem( - severity=reports.item.get_severity( - report_codes.FORCE, - force_quorum_loss, - ), - message=reports.messages.CorosyncQuorumWillBeLost(), - ) - ) - elif failures or not targets_to_remove: - report_processor.report( - ReportItem( - severity=reports.item.get_severity( - report_codes.FORCE, - force_quorum_loss, - ), - message=reports.messages.CorosyncQuorumLossUnableToCheck(), - ) - ) - - if report_processor.has_errors: - raise LibraryError() - - # validations done - - unknown_to_remove = [name for name in unknown_nodes if name in node_list] - if unknown_to_remove: - report_processor.report( - ReportItem.warning( - reports.messages.NodesToRemoveUnreachable( - sorted(unknown_to_remove) - ) - ) - ) - if targets_to_remove: - com_cmd = cluster.DestroyWarnOnFailure(report_processor) - com_cmd.set_targets(targets_to_remove) - run_and_raise(env.get_node_communicator(), com_cmd) - - corosync_conf.remove_nodes(node_list) - if atb_has_to_be_enabled: - corosync_conf.set_quorum_options(dict(auto_tie_breaker="1")) - - _verify_corosync_conf(corosync_conf) # raises if corosync not valid - com_cmd = DistributeCorosyncConf( - env.report_processor, - corosync_conf.config.export(), - allow_skip_offline=False, - ) - com_cmd.set_targets(staying_online_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - com_cmd = ReloadCorosyncConf(env.report_processor) - com_cmd.set_targets(staying_online_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - # try to remove nodes from pcmk using crm_node -R --force and if not - # successful remove it directly from CIB file on all nodes in parallel - com_cmd = RemoveNodesFromCib(env.report_processor, node_list) - com_cmd.set_targets(staying_online_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - -def remove_nodes_from_cib(env: LibraryEnvironment, node_list): - """ - Remove specified nodes from CIB. When pcmk is running 'crm_node -R ' - will be used. Otherwise nodes will be removed directly from CIB file. - - env LibraryEnvironment - node_list iterable -- names of nodes to remove - """ - # TODO: more advanced error handling - if not env.is_cib_live: - raise LibraryError( - ReportItem.error( - reports.messages.LiveEnvironmentRequired([file_type_codes.CIB]) - ) - ) - - if env.service_manager.is_running("pacemaker"): - for node in node_list: - # this may raise a LibraryError - # NOTE: crm_node cannot remove multiple nodes at once - remove_node(env.cmd_runner(), node) - return - - # TODO: We need to remove nodes from the CIB file. We don't want to do it - # using environment as this is a special case in which we have to edit CIB - # file directly. - for node in node_list: - stdout, stderr, retval = env.cmd_runner().run( - [ - settings.cibadmin_exec, - "--delete-all", - "--force", - f"--xpath=/cib/configuration/nodes/node[@uname='{node}']", - ], - env_extend={"CIB_file": os.path.join(settings.cib_dir, "cib.xml")}, - ) - if retval != 0: - raise LibraryError( - ReportItem.error( - reports.messages.NodeRemoveInPacemakerFailed( - node_list_to_remove=[node], - reason=join_multilines([stderr, stdout]), - ) - ) - ) - - -def add_link( - env: LibraryEnvironment, - node_addr_map, - link_options=None, - force_flags: Collection[reports.types.ForceCode] = (), -): - """ - Add a corosync link to a cluster - - env LibraryEnvironment - dict node_addr_map -- key: node name, value: node address for the link - dict link_options -- link options - force_flags list -- list of flags codes - """ - _ensure_live_env(env) # raises if env is not live - - link_options = link_options or {} - force = report_codes.FORCE in force_flags - skip_offline = report_codes.SKIP_OFFLINE_NODES in force_flags - - report_processor = env.report_processor - corosync_conf = env.get_corosync_conf() - - # validations - - dummy_cluster_nodes_names, nodes_report_list = get_existing_nodes_names( - corosync_conf, - # New link addresses are assigned to nodes based on node names. If - # there are nodes with no names, we cannot assign them new link - # addresses. This is a no-go situation. - error_on_missing_name=True, - ) - report_processor.report_list(nodes_report_list) - - try: - cib = env.get_cib() - cib_nodes = get_remote_nodes(cib) + get_guest_nodes(cib) - except LibraryError: - cib_nodes = [] - report_processor.report( - ReportItem( - reports.item.get_severity(report_codes.FORCE, force), - reports.messages.CibLoadErrorGetNodesForValidation(), - ) - ) - - report_processor.report_list( - config_validators.add_link( - node_addr_map, - link_options, - corosync_conf.get_nodes(), - cib_nodes, - [str(num) for num in corosync_conf.get_used_linknumber_list()], - corosync_conf.get_transport(), - corosync_conf.get_ip_version(), - force_unresolvable=force, - ) - ) - - if report_processor.has_errors: - raise LibraryError() - - # validations done - - corosync_conf.add_link(node_addr_map, link_options) - env.push_corosync_conf(corosync_conf, skip_offline) - - -def remove_links( - env: LibraryEnvironment, - linknumber_list, - force_flags: Collection[reports.types.ForceCode] = (), -): - """ - Remove corosync links from a cluster - - env LibraryEnvironment - iterable linknumber_list -- linknumbers (as strings) of links to be removed - force_flags list -- list of flags codes - """ - # TODO library interface should make sure linknumber_list is an iterable of - # strings. The layer in which the check should be done does not exist yet. - _ensure_live_env(env) # raises if env is not live - - skip_offline = report_codes.SKIP_OFFLINE_NODES in force_flags - - report_processor = env.report_processor - corosync_conf = env.get_corosync_conf() - - # validations - - report_processor.report_list( - config_validators.remove_links( - linknumber_list, - [str(num) for num in corosync_conf.get_used_linknumber_list()], - corosync_conf.get_transport(), - ) - ) - - if report_processor.has_errors: - raise LibraryError() - - # validations done - - corosync_conf.remove_links(linknumber_list) - - env.push_corosync_conf(corosync_conf, skip_offline) - - -def update_link( - env: LibraryEnvironment, - linknumber, - node_addr_map=None, - link_options=None, - force_flags: Collection[reports.types.ForceCode] = (), -): - """ - Change an existing corosync link - - env LibraryEnvironment - string linknumber -- the link to be changed - dict node_addr_map -- key: node name, value: node address for the link - dict link_options -- link options - force_flags list -- list of flags codes - """ - _ensure_live_env(env) # raises if env is not live - - node_addr_map = node_addr_map or {} - link_options = link_options or {} - force = report_codes.FORCE in force_flags - skip_offline = report_codes.SKIP_OFFLINE_NODES in force_flags - - report_processor = env.report_processor - corosync_conf = env.get_corosync_conf() - - # validations - - dummy_cluster_nodes_names, nodes_report_list = get_existing_nodes_names( - corosync_conf, - # Pcs is unable to communicate with nodes missing names. It cannot send - # new corosync.conf to them. That might break the cluster. Hence we - # error out. - # This check is done later as well, when sending corosync.conf to - # nodes. But we need node names to be present so we can set new - # addresses to them. We may as well do the check right now. - error_on_missing_name=True, - ) - report_processor.report_list(nodes_report_list) - - report_processor.report_list( - config_validators.update_link( - linknumber, - node_addr_map, - link_options, - corosync_conf.get_links_options().get(linknumber, {}), - corosync_conf.get_nodes(), - # cluster must be stopped for updating a link and then we cannot get - # nodes from CIB - [], - [str(num) for num in corosync_conf.get_used_linknumber_list()], - corosync_conf.get_transport(), - corosync_conf.get_ip_version(), - force_unresolvable=force, - ) - ) - - if report_processor.has_errors: - raise LibraryError() - - # validations done - - corosync_conf.update_link(linknumber, node_addr_map, link_options) - env.push_corosync_conf(corosync_conf, skip_offline) - - -def corosync_authkey_change( - env: LibraryEnvironment, - corosync_authkey: Optional[bytes] = None, - force_flags: Collection[reports.types.ForceCode] = (), -) -> None: - """ - Distribute new corosync authkey to all cluster nodes. - - env -- LibraryEnvironment - corosync_authkey -- new authkey; if None, generate a random one - force_flags -- list of flags codes - """ - report_processor = env.report_processor - target_factory = env.get_node_target_factory() - - cluster_nodes_names, nodes_report_list = get_existing_nodes_names( - env.get_corosync_conf(), - error_on_missing_name=True, - ) - report_processor.report_list(nodes_report_list) - ( - target_report_list, - cluster_nodes_target_list, - ) = target_factory.get_target_list_with_reports( - cluster_nodes_names, - allow_skip=False, - ) - report_processor.report_list(target_report_list) - if corosync_authkey is not None: - if len(corosync_authkey) != settings.corosync_authkey_bytes: - report_processor.report( - ReportItem( - severity=reports.item.get_severity( - report_codes.FORCE, - report_codes.FORCE in force_flags, - ), - message=reports.messages.CorosyncAuthkeyWrongLength( - len(corosync_authkey), - settings.corosync_authkey_bytes, - settings.corosync_authkey_bytes, - ), - ) - ) - else: - corosync_authkey = generate_binary_key( - random_bytes_count=settings.corosync_authkey_bytes - ) - - if report_processor.has_errors: - raise LibraryError() - - com_cmd: AllSameDataMixin = GetOnlineTargets( - report_processor, - ignore_offline_targets=report_codes.SKIP_OFFLINE_NODES in force_flags, - ) - com_cmd.set_targets(cluster_nodes_target_list) - online_cluster_target_list = run_and_raise( - env.get_node_communicator(), com_cmd - ) - - if not online_cluster_target_list: - if report_processor.report( - ReportItem.error( - reports.messages.UnableToPerformOperationOnAnyNode() - ) - ).has_errors: - raise LibraryError() - - com_cmd = DistributeFilesWithoutForces( - env.report_processor, - node_communication_format.corosync_authkey_file(corosync_authkey), - ) - com_cmd.set_targets(online_cluster_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - com_cmd = ReloadCorosyncConf(env.report_processor) - com_cmd.set_targets(online_cluster_target_list) - run_and_raise(env.get_node_communicator(), com_cmd) - - -def _generate_cluster_uuid( - corosync_conf: config_facade.ConfigFacade, is_forced: bool -) -> Tuple[ReportItemList, config_facade.ConfigFacade]: - report_list = [] - if corosync_conf.get_cluster_uuid(): - report_list.append( - reports.ReportItem( - severity=reports.item.get_severity( - report_codes.FORCE, is_forced - ), - message=reports.messages.ClusterUuidAlreadySet(), - ) - ) - if not is_forced: - return report_list, corosync_conf - - corosync_conf.set_cluster_uuid(generate_uuid()) - return report_list, corosync_conf - - -def generate_cluster_uuid( - env: LibraryEnvironment, - force_flags: Collection[reports.types.ForceCode] = (), -) -> None: - """ - Add or update cluster UUID in live cluster - - env - """ - _ensure_live_env(env) - corosync_conf = env.get_corosync_conf() - report_list, corosync_conf = _generate_cluster_uuid( - corosync_conf, report_codes.FORCE in force_flags - ) - if env.report_processor.report_list(report_list).has_errors: - raise LibraryError() - env.push_corosync_conf(corosync_conf) - - -def generate_cluster_uuid_local( - env: LibraryEnvironment, - corosync_conf_content: bytes, - force_flags: Collection[reports.types.ForceCode] = (), -) -> bytes: - """ - Add or update cluster UUID in corosync.conf passed as an argument and return - the updated config - - env - corosync_conf_content -- corosync.conf to be updated - """ - _ensure_live_env(env) - corosync_conf_instance = FileInstance.for_corosync_conf() - try: - corosync_conf: config_facade.ConfigFacade = cast( - config_facade.ConfigFacade, - corosync_conf_instance.raw_to_facade(corosync_conf_content), - ) - except ParserErrorException as e: - if env.report_processor.report_list( - corosync_conf_instance.toolbox.parser.exception_to_report_list( - e, - corosync_conf_instance.toolbox.file_type_code, - None, - force_code=None, - is_forced_or_warning=False, - ) - ).has_errors: - raise LibraryError() from e - - report_list, corosync_conf = _generate_cluster_uuid( - corosync_conf, report_codes.FORCE in force_flags - ) - if env.report_processor.report_list(report_list).has_errors: - raise LibraryError() - - return corosync_conf_instance.facade_to_raw(corosync_conf) - - -def wait_for_pcmk_idle(env: LibraryEnvironment, wait_value: WaitType) -> None: - """ - Wait for the cluster to settle into stable state. - - env - wait_value -- value describing the timeout the command - """ - timeout = env.ensure_wait_satisfiable(wait_value) - env.wait_for_idle(timeout) diff --git a/pcs/lib/commands/cluster/__init__.py b/pcs/lib/commands/cluster/__init__.py new file mode 100644 index 000000000..963eabcbc --- /dev/null +++ b/pcs/lib/commands/cluster/__init__.py @@ -0,0 +1,41 @@ +from .config import ( + config_update, + config_update_local, + generate_cluster_uuid, + generate_cluster_uuid_local, + get_corosync_conf_struct, +) +from .link import add_link, remove_links, update_link +from .misc import corosync_authkey_change, rename, verify, wait_for_pcmk_idle +from .node import ( + node_clear, + remove_nodes, + remove_nodes_from_cib, + rename_node_cib, + rename_node_corosync, +) +from .setup_cluster import setup, setup_local +from .setup_node import add_nodes + +__all__ = [ + "add_link", + "add_nodes", + "config_update", + "config_update_local", + "corosync_authkey_change", + "generate_cluster_uuid", + "generate_cluster_uuid_local", + "get_corosync_conf_struct", + "node_clear", + "remove_links", + "remove_nodes", + "remove_nodes_from_cib", + "rename", + "rename_node_cib", + "rename_node_corosync", + "setup", + "setup_local", + "update_link", + "verify", + "wait_for_pcmk_idle", +] diff --git a/pcs/lib/commands/cluster/config.py b/pcs/lib/commands/cluster/config.py new file mode 100644 index 000000000..f06072194 --- /dev/null +++ b/pcs/lib/commands/cluster/config.py @@ -0,0 +1,269 @@ +from typing import Mapping, Optional, cast + +from pcs.common import reports +from pcs.common.corosync_conf import ( + CorosyncConfDto, + CorosyncQuorumDeviceSettingsDto, +) +from pcs.common.types import ( + CorosyncTransportType, + UnknownCorosyncTransportTypeException, +) +from pcs.lib.commands.cluster.utils import ensure_live_env, verify_corosync_conf +from pcs.lib.corosync import config_facade, config_validators +from pcs.lib.corosync import constants as corosync_constants +from pcs.lib.env import LibraryEnvironment +from pcs.lib.errors import LibraryError +from pcs.lib.file.instance import FileInstance +from pcs.lib.interface.config import ParserErrorException +from pcs.lib.tools import generate_uuid + + +def _config_update( + report_processor: reports.ReportProcessor, + corosync_conf: config_facade.ConfigFacade, + transport_options: Mapping[str, str], + compression_options: Mapping[str, str], + crypto_options: Mapping[str, str], + totem_options: Mapping[str, str], +) -> None: + transport_type = corosync_conf.get_transport() + report_list = config_validators.update_totem(totem_options) + if transport_type in corosync_constants.TRANSPORTS_KNET: + report_list += config_validators.update_transport_knet( + transport_options, + compression_options, + crypto_options, + corosync_conf.get_crypto_options(), + ) + elif transport_type in corosync_constants.TRANSPORTS_UDP: + report_list += config_validators.update_transport_udp( + transport_options, + compression_options, + crypto_options, + ) + else: + report_processor.report( + reports.ReportItem.error( + reports.messages.CorosyncConfigUnsupportedTransport( + transport_type, sorted(corosync_constants.TRANSPORTS_ALL) + ) + ) + ) + if report_processor.report_list(report_list).has_errors: + raise LibraryError() + + corosync_conf.set_totem_options(totem_options) + corosync_conf.set_transport_options( + transport_options, + compression_options, + crypto_options, + ) + verify_corosync_conf(corosync_conf) # raises if corosync not valid + + +def config_update( + env: LibraryEnvironment, + transport_options: Mapping[str, str], + compression_options: Mapping[str, str], + crypto_options: Mapping[str, str], + totem_options: Mapping[str, str], +) -> None: + """ + Update corosync.conf in the local cluster + + env + transport_options -- transport specific options + compression_options -- only available for knet transport. In + corosync.conf they are prefixed 'knet_compression_' + crypto_options -- only available for knet transport. In corosync.conf + they are prefixed 'crypto_' + totem_options -- options of section 'totem' in corosync.conf + """ + ensure_live_env(env) + corosync_conf = env.get_corosync_conf() + _config_update( + env.report_processor, + corosync_conf, + transport_options, + compression_options, + crypto_options, + totem_options, + ) + env.push_corosync_conf(corosync_conf) + + +def config_update_local( + env: LibraryEnvironment, + corosync_conf_content: bytes, + transport_options: Mapping[str, str], + compression_options: Mapping[str, str], + crypto_options: Mapping[str, str], + totem_options: Mapping[str, str], +) -> bytes: + """ + Update corosync.conf passed as an argument and return the updated conf + + env + corosync_conf_content -- corosync.conf to be updated + transport_options -- transport specific options + compression_options -- only available for knet transport. In + corosync.conf they are prefixed 'knet_compression_' + crypto_options -- only available for knet transport. In corosync.conf + they are prefixed 'crypto_' + totem_options -- options of section 'totem' in corosync.conf + """ + # As we are getting a corosync.conf content as an argument, we want to make + # sure it was not given to LibraryEnvironment as well. Also we don't + # allow/need CIB to be handled by LibraryEnvironment. + ensure_live_env(env) + corosync_conf_instance = FileInstance.for_corosync_conf() + try: + corosync_conf: config_facade.ConfigFacade = cast( + config_facade.ConfigFacade, + corosync_conf_instance.raw_to_facade(corosync_conf_content), + ) + except ParserErrorException as e: + if env.report_processor.report_list( + corosync_conf_instance.toolbox.parser.exception_to_report_list( + e, + corosync_conf_instance.toolbox.file_type_code, + None, + force_code=None, + is_forced_or_warning=False, + ) + ).has_errors: + raise LibraryError() from e + _config_update( + env.report_processor, + corosync_conf, + transport_options, + compression_options, + crypto_options, + totem_options, + ) + return corosync_conf_instance.facade_to_raw(corosync_conf) + + +def get_corosync_conf_struct(env: LibraryEnvironment) -> CorosyncConfDto: + """ + Read corosync.conf from the local node and return it in a structured form + """ + corosync_conf = env.get_corosync_conf() + quorum_device_dto: Optional[CorosyncQuorumDeviceSettingsDto] = None + qd_model = corosync_conf.get_quorum_device_model() + if qd_model is not None: + ( + qd_model_options, + qd_generic_options, + qd_heuristics_options, + ) = corosync_conf.get_quorum_device_settings() + quorum_device_dto = CorosyncQuorumDeviceSettingsDto( + model=qd_model, + model_options=qd_model_options, + generic_options=qd_generic_options, + heuristics_options=qd_heuristics_options, + ) + try: + return CorosyncConfDto( + cluster_name=corosync_conf.get_cluster_name(), + cluster_uuid=corosync_conf.get_cluster_uuid(), + transport=CorosyncTransportType.from_str( + corosync_conf.get_transport() + ), + totem_options=corosync_conf.get_totem_options(), + transport_options=corosync_conf.get_transport_options(), + compression_options=corosync_conf.get_compression_options(), + crypto_options=corosync_conf.get_crypto_options(), + nodes=[node.to_dto() for node in corosync_conf.get_nodes()], + links_options=corosync_conf.get_links_options(), + quorum_options=corosync_conf.get_quorum_options(), + quorum_device=quorum_device_dto, + ) + except UnknownCorosyncTransportTypeException as e: + raise LibraryError( + reports.ReportItem.error( + reports.messages.CorosyncConfigUnsupportedTransport( + e.transport, sorted(corosync_constants.TRANSPORTS_ALL) + ) + ) + ) from e + + +def _generate_cluster_uuid( + corosync_conf: config_facade.ConfigFacade, is_forced: bool +) -> tuple[reports.ReportItemList, config_facade.ConfigFacade]: + report_list = [] + if corosync_conf.get_cluster_uuid(): + report_list.append( + reports.ReportItem( + severity=reports.item.get_severity( + reports.codes.FORCE, is_forced + ), + message=reports.messages.ClusterUuidAlreadySet(), + ) + ) + if not is_forced: + return report_list, corosync_conf + + corosync_conf.set_cluster_uuid(generate_uuid()) + return report_list, corosync_conf + + +def generate_cluster_uuid( + env: LibraryEnvironment, + force_flags: reports.types.ForceFlags = (), +) -> None: + """ + Add or update cluster UUID in live cluster + + env + """ + ensure_live_env(env) + corosync_conf = env.get_corosync_conf() + report_list, corosync_conf = _generate_cluster_uuid( + corosync_conf, reports.codes.FORCE in force_flags + ) + if env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + env.push_corosync_conf(corosync_conf) + + +def generate_cluster_uuid_local( + env: LibraryEnvironment, + corosync_conf_content: bytes, + force_flags: reports.types.ForceFlags = (), +) -> bytes: + """ + Add or update cluster UUID in corosync.conf passed as an argument and return + the updated config + + env + corosync_conf_content -- corosync.conf to be updated + """ + ensure_live_env(env) + corosync_conf_instance = FileInstance.for_corosync_conf() + try: + corosync_conf: config_facade.ConfigFacade = cast( + config_facade.ConfigFacade, + corosync_conf_instance.raw_to_facade(corosync_conf_content), + ) + except ParserErrorException as e: + if env.report_processor.report_list( + corosync_conf_instance.toolbox.parser.exception_to_report_list( + e, + corosync_conf_instance.toolbox.file_type_code, + None, + force_code=None, + is_forced_or_warning=False, + ) + ).has_errors: + raise LibraryError() from e + + report_list, corosync_conf = _generate_cluster_uuid( + corosync_conf, reports.codes.FORCE in force_flags + ) + if env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + + return corosync_conf_instance.facade_to_raw(corosync_conf) diff --git a/pcs/lib/commands/cluster/link.py b/pcs/lib/commands/cluster/link.py new file mode 100644 index 000000000..653adf02c --- /dev/null +++ b/pcs/lib/commands/cluster/link.py @@ -0,0 +1,183 @@ +from pcs.common import reports +from pcs.lib.cib.resource.guest_node import find_node_list as get_guest_nodes +from pcs.lib.cib.resource.remote_node import find_node_list as get_remote_nodes +from pcs.lib.commands.cluster.utils import ensure_live_env +from pcs.lib.corosync import config_validators +from pcs.lib.env import LibraryEnvironment +from pcs.lib.errors import LibraryError +from pcs.lib.node import get_existing_nodes_names + + +def add_link( + env: LibraryEnvironment, + node_addr_map, + link_options=None, + force_flags: reports.types.ForceFlags = (), +) -> None: + """ + Add a corosync link to a cluster + + env LibraryEnvironment + dict node_addr_map -- key: node name, value: node address for the link + dict link_options -- link options + force_flags list -- list of flags codes + """ + ensure_live_env(env) # raises if env is not live + + link_options = link_options or {} + force = reports.codes.FORCE in force_flags + skip_offline = reports.codes.SKIP_OFFLINE_NODES in force_flags + + report_processor = env.report_processor + corosync_conf = env.get_corosync_conf() + + # validations + + dummy_cluster_nodes_names, nodes_report_list = get_existing_nodes_names( + corosync_conf, + # New link addresses are assigned to nodes based on node names. If + # there are nodes with no names, we cannot assign them new link + # addresses. This is a no-go situation. + error_on_missing_name=True, + ) + report_processor.report_list(nodes_report_list) + + try: + cib = env.get_cib() + cib_nodes = get_remote_nodes(cib) + get_guest_nodes(cib) + except LibraryError: + cib_nodes = [] + report_processor.report( + reports.ReportItem( + reports.item.get_severity(reports.codes.FORCE, force), + reports.messages.CibLoadErrorGetNodesForValidation(), + ) + ) + + report_processor.report_list( + config_validators.add_link( + node_addr_map, + link_options, + corosync_conf.get_nodes(), + cib_nodes, + [str(num) for num in corosync_conf.get_used_linknumber_list()], + corosync_conf.get_transport(), + corosync_conf.get_ip_version(), + force_unresolvable=force, + ) + ) + + if report_processor.has_errors: + raise LibraryError() + + # validations done + + corosync_conf.add_link(node_addr_map, link_options) + env.push_corosync_conf(corosync_conf, skip_offline) + + +def remove_links( + env: LibraryEnvironment, + linknumber_list, + force_flags: reports.types.ForceFlags = (), +) -> None: + """ + Remove corosync links from a cluster + + env LibraryEnvironment + iterable linknumber_list -- linknumbers (as strings) of links to be removed + force_flags list -- list of flags codes + """ + # TODO library interface should make sure linknumber_list is an iterable of + # strings. The layer in which the check should be done does not exist yet. + ensure_live_env(env) # raises if env is not live + + skip_offline = reports.codes.SKIP_OFFLINE_NODES in force_flags + + report_processor = env.report_processor + corosync_conf = env.get_corosync_conf() + + # validations + + report_processor.report_list( + config_validators.remove_links( + linknumber_list, + [str(num) for num in corosync_conf.get_used_linknumber_list()], + corosync_conf.get_transport(), + ) + ) + + if report_processor.has_errors: + raise LibraryError() + + # validations done + + corosync_conf.remove_links(linknumber_list) + + env.push_corosync_conf(corosync_conf, skip_offline) + + +def update_link( + env: LibraryEnvironment, + linknumber, + node_addr_map=None, + link_options=None, + force_flags: reports.types.ForceFlags = (), +) -> None: + """ + Change an existing corosync link + + env LibraryEnvironment + string linknumber -- the link to be changed + dict node_addr_map -- key: node name, value: node address for the link + dict link_options -- link options + force_flags list -- list of flags codes + """ + ensure_live_env(env) # raises if env is not live + + node_addr_map = node_addr_map or {} + link_options = link_options or {} + force = reports.codes.FORCE in force_flags + skip_offline = reports.codes.SKIP_OFFLINE_NODES in force_flags + + report_processor = env.report_processor + corosync_conf = env.get_corosync_conf() + + # validations + + dummy_cluster_nodes_names, nodes_report_list = get_existing_nodes_names( + corosync_conf, + # Pcs is unable to communicate with nodes missing names. It cannot send + # new corosync.conf to them. That might break the cluster. Hence we + # error out. + # This check is done later as well, when sending corosync.conf to + # nodes. But we need node names to be present so we can set new + # addresses to them. We may as well do the check right now. + error_on_missing_name=True, + ) + report_processor.report_list(nodes_report_list) + + report_processor.report_list( + config_validators.update_link( + linknumber, + node_addr_map, + link_options, + corosync_conf.get_links_options().get(linknumber, {}), + corosync_conf.get_nodes(), + # cluster must be stopped for updating a link and then we cannot get + # nodes from CIB + [], + [str(num) for num in corosync_conf.get_used_linknumber_list()], + corosync_conf.get_transport(), + corosync_conf.get_ip_version(), + force_unresolvable=force, + ) + ) + + if report_processor.has_errors: + raise LibraryError() + + # validations done + + corosync_conf.update_link(linknumber, node_addr_map, link_options) + env.push_corosync_conf(corosync_conf, skip_offline) diff --git a/pcs/lib/commands/cluster/misc.py b/pcs/lib/commands/cluster/misc.py new file mode 100644 index 000000000..5c42566d3 --- /dev/null +++ b/pcs/lib/commands/cluster/misc.py @@ -0,0 +1,274 @@ +from typing import Optional + +from lxml.etree import _Element + +from pcs import settings +from pcs.common import reports +from pcs.lib import node_communication_format +from pcs.lib.cib import fencing_topology +from pcs.lib.cib.nvpair_multi import NVSET_INSTANCE, find_nvsets +from pcs.lib.cib.resource.primitive import find_primitives_by_agent +from pcs.lib.cib.tools import get_resources +from pcs.lib.commands.cluster.utils import ensure_live_env +from pcs.lib.communication import cluster +from pcs.lib.communication.corosync import ( + CheckCorosyncOffline, + ReloadCorosyncConf, +) +from pcs.lib.communication.nodes import ( + DistributeFilesWithoutForces, + GetOnlineTargets, +) +from pcs.lib.communication.tools import ( + AllSameDataMixin, + CommunicationCommandInterface, + run, + run_and_raise, +) +from pcs.lib.corosync import config_validators +from pcs.lib.env import LibraryEnvironment, WaitType +from pcs.lib.errors import LibraryError +from pcs.lib.node import get_existing_nodes_names +from pcs.lib.pacemaker.live import ( + get_cib, + get_cib_file_runner_env, + get_cib_xml, + get_cib_xml_cmd_results, + has_cib_xml, +) +from pcs.lib.pacemaker.live import verify as verify_cmd +from pcs.lib.pacemaker.state import ClusterState +from pcs.lib.resource_agent.types import ResourceAgentName +from pcs.lib.tools import generate_binary_key + + +def verify(env: LibraryEnvironment, verbose: bool = False) -> None: + runner = env.cmd_runner() + ( + dummy_stdout, + verify_stderr, + verify_returncode, + can_be_more_verbose, + ) = verify_cmd(runner, verbose=verbose) + + # 1) Do not even try to think about upgrading! + # 2) We do not need cib management in env (no need for push...). + # So env.get_cib is not best choice here (there were considerations to + # upgrade cib at all times inside env.get_cib). Go to a lower level here. + if verify_returncode != 0: + env.report_processor.report( + reports.ReportItem.error( + reports.messages.InvalidCibContent( + verify_stderr, + can_be_more_verbose, + ) + ) + ) + + # Cib is sometimes loadable even if `crm_verify` fails (e.g. when + # fencing topology is invalid). On the other hand cib with id + # duplication is not loadable. + # We try extra checks when cib is possible to load. + cib_xml, dummy_stderr, returncode = get_cib_xml_cmd_results(runner) + if returncode != 0: + raise LibraryError() + else: + cib_xml = get_cib_xml(runner) + + cib = get_cib(cib_xml) + env.report_processor.report_list( + fencing_topology.verify( + cib, + ClusterState(env.get_cluster_state()).node_section.nodes, + ) + ) + if env.report_processor.has_errors: + raise LibraryError() + + +def corosync_authkey_change( + env: LibraryEnvironment, + corosync_authkey: Optional[bytes] = None, + force_flags: reports.types.ForceFlags = (), +) -> None: + """ + Distribute new corosync authkey to all cluster nodes. + + env -- LibraryEnvironment + corosync_authkey -- new authkey; if None, generate a random one + force_flags -- list of flags codes + """ + report_processor = env.report_processor + target_factory = env.get_node_target_factory() + + cluster_nodes_names, nodes_report_list = get_existing_nodes_names( + env.get_corosync_conf(), + error_on_missing_name=True, + ) + report_processor.report_list(nodes_report_list) + ( + target_report_list, + cluster_nodes_target_list, + ) = target_factory.get_target_list_with_reports( + cluster_nodes_names, + allow_skip=False, + ) + report_processor.report_list(target_report_list) + if corosync_authkey is not None: + if len(corosync_authkey) != settings.corosync_authkey_bytes: + report_processor.report( + reports.ReportItem( + severity=reports.item.get_severity( + reports.codes.FORCE, + reports.codes.FORCE in force_flags, + ), + message=reports.messages.CorosyncAuthkeyWrongLength( + len(corosync_authkey), + settings.corosync_authkey_bytes, + settings.corosync_authkey_bytes, + ), + ) + ) + else: + corosync_authkey = generate_binary_key( + random_bytes_count=settings.corosync_authkey_bytes + ) + + if report_processor.has_errors: + raise LibraryError() + + com_cmd: AllSameDataMixin = GetOnlineTargets( + report_processor, + ignore_offline_targets=reports.codes.SKIP_OFFLINE_NODES in force_flags, + ) + com_cmd.set_targets(cluster_nodes_target_list) + online_cluster_target_list = run_and_raise( + env.get_node_communicator(), com_cmd + ) + + if not online_cluster_target_list: + report_processor.report( + reports.ReportItem.error( + reports.messages.UnableToPerformOperationOnAnyNode() + ) + ) + if report_processor.has_errors: + raise LibraryError() + + com_cmd = DistributeFilesWithoutForces( + env.report_processor, + node_communication_format.corosync_authkey_file(corosync_authkey), + ) + com_cmd.set_targets(online_cluster_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + com_cmd = ReloadCorosyncConf(env.report_processor) + com_cmd.set_targets(online_cluster_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + +def wait_for_pcmk_idle(env: LibraryEnvironment, wait_value: WaitType) -> None: + """ + Wait for the cluster to settle into stable state. + + env + wait_value -- value describing the timeout the command + """ + timeout = env.ensure_wait_satisfiable(wait_value) + env.wait_for_idle(timeout) + + +def rename( + env: LibraryEnvironment, + new_name: str, + force_flags: reports.types.ForceFlags = (), +) -> None: + """ + Change the name of the local cluster + + new_name -- new name for the cluster + """ + + def warn_dlm_resources(resources: _Element) -> reports.ReportItemList: + if find_primitives_by_agent( + resources, ResourceAgentName("ocf", "pacemaker", "controld") + ): + return [ + reports.ReportItem.warning( + reports.messages.DlmClusterRenameNeeded() + ) + ] + return [] + + def warn_gfs2_resources(resources: _Element) -> reports.ReportItemList: + for resource in find_primitives_by_agent( + resources, ResourceAgentName("ocf", "heartbeat", "Filesystem") + ): + for nvset in find_nvsets(resource, NVSET_INSTANCE): + if any( + ( + nvpair.get("name") == "fstype" + and nvpair.get("value") == "gfs2" + ) + for nvpair in nvset + ): + return [ + reports.ReportItem.warning( + reports.messages.Gfs2LockTableRenameNeeded() + ) + ] + return [] + + ensure_live_env(env) + + if env.report_processor.report_list( + config_validators.rename_cluster( + new_name, force_cluster_name=reports.codes.FORCE in force_flags + ) + ).has_errors: + raise LibraryError() + + cib = None + if has_cib_xml(): + cib = get_cib(get_cib_xml(env.cmd_runner(get_cib_file_runner_env()))) + resources = get_resources(cib) + env.report_processor.report_list(warn_dlm_resources(resources)) + env.report_processor.report_list(warn_gfs2_resources(resources)) + + corosync_conf = env.get_corosync_conf() + skip_offline = reports.codes.SKIP_OFFLINE_NODES in force_flags + + corosync_nodes, report_list = get_existing_nodes_names(corosync_conf, None) + if env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + target_list = env.get_node_target_factory().get_target_list( + corosync_nodes, + allow_skip=skip_offline, + ) + + node_communicator = env.get_node_communicator() + com_cmd: CommunicationCommandInterface + + com_cmd = CheckCorosyncOffline(env.report_processor, skip_offline) + com_cmd.set_targets(target_list) + running_targets = run(node_communicator, com_cmd) + if running_targets: + env.report_processor.report( + reports.ReportItem.error( + reports.messages.CorosyncNotRunningCheckFinishedRunning( + [target.label for target in running_targets] + ) + ) + ) + if env.report_processor.has_errors: + raise LibraryError() + + # The 'cluster-name' property has to be removed from CIB on all nodes, so + # that it is initialized with the new cluster name from corosync after the + # cluster is started + com_cmd = cluster.RemoveCibClusterName(env.report_processor, skip_offline) + com_cmd.set_targets(target_list) + run_and_raise(node_communicator, com_cmd) + + corosync_conf.set_cluster_name(new_name) + env.push_corosync_conf(corosync_conf, skip_offline) diff --git a/pcs/lib/commands/cluster/node.py b/pcs/lib/commands/cluster/node.py new file mode 100644 index 000000000..b5623f757 --- /dev/null +++ b/pcs/lib/commands/cluster/node.py @@ -0,0 +1,450 @@ +from pcs import settings +from pcs.common import file_type_codes, reports +from pcs.common.str_tools import join_multilines +from pcs.lib import sbd +from pcs.lib.cib.node_rename import rename_in_cib +from pcs.lib.commands.cluster.utils import ensure_live_env, verify_corosync_conf +from pcs.lib.communication import cluster +from pcs.lib.communication.corosync import ( + CheckCorosyncOffline, + DistributeCorosyncConf, + ReloadCorosyncConf, +) +from pcs.lib.communication.nodes import GetOnlineTargets, RemoveNodesFromCib +from pcs.lib.communication.tools import AllSameDataMixin, run_and_raise +from pcs.lib.communication.tools import run as run_com +from pcs.lib.corosync import config_validators +from pcs.lib.env import LibraryEnvironment +from pcs.lib.errors import LibraryError +from pcs.lib.node import get_existing_nodes_names +from pcs.lib.pacemaker.live import get_cib_file_runner_env, remove_node + + +def node_clear( + env: LibraryEnvironment, + node_name: str, + allow_clear_cluster_node: bool = False, +) -> None: + """ + Remove specified node from various cluster caches. + + allow_clear_cluster_node -- flag allows to clear node even if it's + still in a cluster + """ + ensure_live_env(env) # raises if env is not live + + current_nodes, report_list = get_existing_nodes_names( + env.get_corosync_conf(), env.get_cib() + ) + if env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + + if node_name in current_nodes: + env.report_processor.report( + reports.ReportItem( + severity=reports.item.get_severity( + reports.codes.FORCE, + allow_clear_cluster_node, + ), + message=reports.messages.NodeToClearIsStillInCluster(node_name), + ) + ) + if env.report_processor.has_errors: + raise LibraryError() + + remove_node(env.cmd_runner(), node_name) + + +def remove_nodes( # noqa: PLR0912, PLR0915 + env: LibraryEnvironment, + node_list, + force_flags: reports.types.ForceFlags = (), +) -> None: + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + """ + Remove nodes from a cluster. + + env LibraryEnvironment + node_list iterable -- names of nodes to remove + force_flags list -- list of flags codes + """ + ensure_live_env(env) # raises if env is not live + + force_quorum_loss = reports.codes.FORCE in force_flags + skip_offline = reports.codes.SKIP_OFFLINE_NODES in force_flags + + report_processor = env.report_processor + target_factory = env.get_node_target_factory() + corosync_conf = env.get_corosync_conf() + + # validations + + cluster_nodes_names, report_list = get_existing_nodes_names( + corosync_conf, + # Pcs is unable to communicate with nodes missing names. It cannot send + # new corosync.conf to them. That might break the cluster. Hence we + # error out. + error_on_missing_name=True, + ) + report_processor.report_list(report_list) + + report_processor.report_list( + config_validators.remove_nodes( + node_list, + corosync_conf.get_nodes(), + corosync_conf.get_quorum_device_model(), + corosync_conf.get_quorum_device_settings(), + ) + ) + if report_processor.has_errors: + # If there is an error, there is usually not much sense in doing other + # validations: + # - if there would be no node left in the cluster, it's pointless + # to check for quorum loss or if at least one remaining node is online + # - if only one node is being removed and it doesn't exist, it's again + # pointless to check for other issues + raise LibraryError() + + ( + target_report_list, + cluster_nodes_target_list, + ) = target_factory.get_target_list_with_reports( + cluster_nodes_names, + skip_non_existing=skip_offline, + ) + known_nodes = {target.label for target in cluster_nodes_target_list} + unknown_nodes = { + name for name in cluster_nodes_names if name not in known_nodes + } + report_processor.report_list(target_report_list) + + com_cmd: AllSameDataMixin = GetOnlineTargets( + report_processor, + ignore_offline_targets=skip_offline, + ) + com_cmd.set_targets(cluster_nodes_target_list) + online_target_list = run_com(env.get_node_communicator(), com_cmd) + offline_target_list = [ + target + for target in cluster_nodes_target_list + if target not in online_target_list + ] + staying_online_target_list = [ + target for target in online_target_list if target.label not in node_list + ] + targets_to_remove = [ + target + for target in cluster_nodes_target_list + if target.label in node_list + ] + if not staying_online_target_list: + report_processor.report( + reports.ReportItem.error( + reports.messages.UnableToConnectToAnyRemainingNode() + ) + ) + # If no remaining node is online, there is no point in checking quorum + # loss or anything as we would just get errors. + raise LibraryError() + + if skip_offline: + staying_offline_nodes = [ + target.label + for target in offline_target_list + if target.label not in node_list + ] + [name for name in unknown_nodes if name not in node_list] + if staying_offline_nodes: + report_processor.report( + reports.ReportItem.warning( + reports.messages.UnableToConnectToAllRemainingNodes( + sorted(staying_offline_nodes) + ) + ) + ) + + atb_has_to_be_enabled = sbd.atb_has_to_be_enabled( + env.service_manager, corosync_conf, -len(node_list) + ) + if atb_has_to_be_enabled: + com_cmd = CheckCorosyncOffline( + report_processor, allow_skip_offline=False + ) + com_cmd.set_targets(staying_online_target_list) + cluster_running_target_list = run_com( + env.get_node_communicator(), com_cmd + ) + if cluster_running_target_list: + report_processor.report( + reports.ReportItem.error( + reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbdClusterIsRunning() + ) + ) + else: + report_processor.report( + reports.ReportItem.warning( + reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbd() + ) + ) + else: + # Check if removing the nodes would cause quorum loss. We ask the nodes + # to be removed for their view of quorum. If they are all stopped or + # not in a quorate partition, their removal cannot cause quorum loss. + # That's why we ask them and not the remaining nodes. + # example: 5-node cluster, 3 online nodes, removing one online node, + # results in 4-node cluster with 2 online nodes => quorum lost + # Check quorum loss only if ATB does not need to be enabled. If it is + # required, cluster has to be turned off and therefore it loses quorum. + com_cmd = cluster.GetQuorumStatus(report_processor) + com_cmd.set_targets(targets_to_remove) + failures, quorum_status_facade = run_com( + env.get_node_communicator(), com_cmd + ) + if quorum_status_facade: + if quorum_status_facade.stopping_nodes_cause_quorum_loss(node_list): + report_processor.report( + reports.ReportItem( + severity=reports.item.get_severity( + reports.codes.FORCE, + force_quorum_loss, + ), + message=reports.messages.CorosyncQuorumWillBeLost(), + ) + ) + elif failures or not targets_to_remove: + report_processor.report( + reports.ReportItem( + severity=reports.item.get_severity( + reports.codes.FORCE, + force_quorum_loss, + ), + message=reports.messages.CorosyncQuorumLossUnableToCheck(), + ) + ) + + if report_processor.has_errors: + raise LibraryError() + + # validations done + + unknown_to_remove = [name for name in unknown_nodes if name in node_list] + if unknown_to_remove: + report_processor.report( + reports.ReportItem.warning( + reports.messages.NodesToRemoveUnreachable( + sorted(unknown_to_remove) + ) + ) + ) + if targets_to_remove: + com_cmd = cluster.DestroyWarnOnFailure(report_processor) + com_cmd.set_targets(targets_to_remove) + run_and_raise(env.get_node_communicator(), com_cmd) + + corosync_conf.remove_nodes(node_list) + if atb_has_to_be_enabled: + corosync_conf.set_quorum_options(dict(auto_tie_breaker="1")) + + verify_corosync_conf(corosync_conf) # raises if corosync not valid + com_cmd = DistributeCorosyncConf( + env.report_processor, + corosync_conf.config.export(), + allow_skip_offline=False, + ) + com_cmd.set_targets(staying_online_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + com_cmd = ReloadCorosyncConf(env.report_processor) + com_cmd.set_targets(staying_online_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # try to remove nodes from pcmk using crm_node -R --force and if not + # successful remove it directly from CIB file on all nodes in parallel + com_cmd = RemoveNodesFromCib(env.report_processor, node_list) + com_cmd.set_targets(staying_online_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + +def remove_nodes_from_cib(env: LibraryEnvironment, node_list) -> None: + """ + Remove specified nodes from CIB. When pcmk is running 'crm_node -R ' + will be used. Otherwise nodes will be removed directly from CIB file. + + env LibraryEnvironment + node_list iterable -- names of nodes to remove + """ + # TODO: more advanced error handling + if not env.is_cib_live: + raise LibraryError( + reports.ReportItem.error( + reports.messages.LiveEnvironmentRequired([file_type_codes.CIB]) + ) + ) + + if env.service_manager.is_running("pacemaker"): + for node in node_list: + # this may raise a LibraryError + # NOTE: crm_node cannot remove multiple nodes at once + remove_node(env.cmd_runner(), node) + return + + # TODO: We need to remove nodes from the CIB file. We don't want to do it + # using environment as this is a special case in which we have to edit CIB + # file directly. + for node in node_list: + stdout, stderr, retval = env.cmd_runner().run( + [ + settings.cibadmin_exec, + "--delete-all", + "--force", + f"--xpath=/cib/configuration/nodes/node[@uname='{node}']", + ], + env_extend=get_cib_file_runner_env(), + ) + if retval != 0: + raise LibraryError( + reports.ReportItem.error( + reports.messages.NodeRemoveInPacemakerFailed( + node_list_to_remove=[node], + reason=join_multilines([stderr, stdout]), + ) + ) + ) + + +def rename_node_cib( + env: LibraryEnvironment, + old_name: str, + new_name: str, + force_flags: reports.types.ForceFlags = (), +) -> None: + """ + Rename a cluster node in CIB configuration elements. + + old_name -- current node name + new_name -- new node name + """ + + if old_name == new_name: + env.report_processor.report( + reports.ReportItem.error( + reports.messages.NodeRenameNamesEqual(old_name) + ) + ) + raise LibraryError() + + if env.is_cib_live: + if not env.is_corosync_conf_live: + env.report_processor.report( + reports.ReportItem.error( + reports.messages.LiveEnvironmentNotConsistent( + [file_type_codes.COROSYNC_CONF], + [file_type_codes.CIB], + ) + ) + ) + raise LibraryError() + corosync_node_names, corosync_nodes_report_list = ( + get_existing_nodes_names(env.get_corosync_conf()) + ) + force_severity = reports.item.get_severity( + reports.codes.FORCE, + reports.codes.FORCE in force_flags, + ) + if new_name not in corosync_node_names: + corosync_nodes_report_list.append( + reports.ReportItem( + severity=force_severity, + message=reports.messages.CibNodeRenameNewNodeNotInCorosync( + new_name=new_name, + ), + ) + ) + if old_name in corosync_node_names: + corosync_nodes_report_list.append( + reports.ReportItem( + severity=force_severity, + message=reports.messages.CibNodeRenameOldNodeInCorosync( + old_name=old_name, + ), + ) + ) + + env.report_processor.report_list(corosync_nodes_report_list) + + if env.report_processor.has_errors: + raise LibraryError() + + cib = env.get_cib() + cib_updated, report_list = rename_in_cib(cib, old_name, new_name) + env.report_processor.report_list(report_list) + + if cib_updated: + env.push_cib() + return + + env.report_processor.report( + reports.ReportItem.info(reports.messages.CibNodeRenameNoChange()) + ) + + +def rename_node_corosync( + env: LibraryEnvironment, + old_name: str, + new_name: str, + force_flags: reports.types.ForceFlags = (), +) -> None: + """ + Rename a cluster node in corosync.conf and distribute it to all nodes. + + old_name -- current node name + new_name -- new node name + """ + if old_name == new_name: + env.report_processor.report( + reports.ReportItem.error( + reports.messages.NodeRenameNamesEqual(old_name) + ) + ) + raise LibraryError() + + if not env.is_corosync_conf_live: + env.report_processor.report( + reports.ReportItem.error( + reports.messages.LiveEnvironmentRequired( + [file_type_codes.COROSYNC_CONF] + ) + ) + ) + raise LibraryError() + + corosync_conf = env.get_corosync_conf() + corosync_node_names, corosync_nodes_report_list = get_existing_nodes_names( + corosync_conf + ) + if old_name not in corosync_node_names: + corosync_nodes_report_list.append( + reports.ReportItem.error( + reports.messages.CorosyncNodeRenameOldNodeNotFound(old_name) + ) + ) + if new_name in corosync_node_names: + corosync_nodes_report_list.append( + reports.ReportItem.error( + reports.messages.CorosyncNodeRenameNewNodeAlreadyExists( + new_name + ) + ) + ) + env.report_processor.report_list(corosync_nodes_report_list) + + if env.report_processor.has_errors: + raise LibraryError() + + rename_report_list = corosync_conf.rename_node(old_name, new_name) + env.report_processor.report_list(rename_report_list) + env.push_corosync_conf( + corosync_conf, + skip_offline_nodes=(reports.codes.SKIP_OFFLINE_NODES in force_flags), + ) diff --git a/pcs/lib/commands/cluster/setup_cluster.py b/pcs/lib/commands/cluster/setup_cluster.py new file mode 100644 index 000000000..03fea187b --- /dev/null +++ b/pcs/lib/commands/cluster/setup_cluster.py @@ -0,0 +1,510 @@ +from typing import Any, Mapping, Optional, Sequence + +from pcs import settings +from pcs.common import reports, ssl +from pcs.lib import node_communication_format +from pcs.lib.commands.cluster.setup_utils import ( + get_addrs_defaulter, + get_validated_wait_timeout, + host_check_cluster_setup, + is_ssl_cert_sync_enabled, + normalize_dict, + set_defaults_in_dict, + start_cluster, +) +from pcs.lib.commands.cluster.utils import ensure_live_env, verify_corosync_conf +from pcs.lib.communication import cluster +from pcs.lib.communication.nodes import ( + DistributeFilesWithoutForces, + EnableCluster, + GetHostInfo, + RemoveFilesWithoutForces, + SendPcsdSslCertAndKey, + UpdateKnownHosts, +) +from pcs.lib.communication.tools import AllSameDataMixin, run_and_raise +from pcs.lib.communication.tools import run as run_com +from pcs.lib.corosync import config_facade, config_validators +from pcs.lib.corosync import constants as corosync_constants +from pcs.lib.env import LibraryEnvironment, WaitType +from pcs.lib.errors import LibraryError +from pcs.lib.tools import generate_binary_key, generate_uuid + + +def setup( # noqa: PLR0913, PLR0915 + env: LibraryEnvironment, + cluster_name: str, + nodes: Sequence[Mapping[str, Any]], + transport_type: Optional[str] = None, + transport_options: Optional[Mapping[str, str]] = None, + link_list: Optional[Sequence[Mapping[str, str]]] = None, + compression_options: Optional[Mapping[str, str]] = None, + crypto_options: Optional[Mapping[str, str]] = None, + totem_options: Optional[Mapping[str, str]] = None, + quorum_options: Optional[Mapping[str, str]] = None, + wait: WaitType = False, + start: bool = False, + enable: bool = False, + no_keys_sync: bool = False, + no_cluster_uuid: bool = False, + force_flags: reports.types.ForceFlags = (), +) -> None: + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + """ + Set up cluster on specified nodes. + Validation of the inputs is done here. Possible existing clusters are + destroyed (when using force). Authkey files for corosync and pacemaker, + known hosts and newly generated corosync.conf are distributed to all + nodes. + Raise LibraryError on any error. + + env + cluster_name -- name of a cluster to set up + nodes -- list of dicts which represents node. + Supported keys are: name (required), addrs. See note below. + transport_type -- transport type of a cluster + transport_options -- transport specific options + link_list -- list of links, depends of transport_type + compression_options -- only available for knet transport. In + corosync.conf they are prefixed 'knet_compression_' + crypto_options -- only available for knet transport'. In corosync.conf + they are prefixed 'crypto_' + totem_options -- options of section 'totem' in corosync.conf + quorum_options -- options of section 'quorum' in corosync.conf + wait -- specifies if command should try to wait for cluster to start up. + Has no effect start is False. If set to False command will not wait for + cluster to start. If None command will wait for some default timeout. + If int wait set timeout to int value of seconds. + start -- if True start cluster when it is set up + enable -- if True enable cluster when it is set up + no_keys_sync -- if True do not create and distribute files: pcsd ssl + cert and key, pacemaker authkey, corosync authkey + no_cluster_uuid -- if True, do not generate a unique cluster UUID into + the 'totem' section of corosync.conf + force_flags -- list of flags codes + + The command is defaulting node addresses if they are not specified. The + defaulting is done for each node individually if and only if the "addrs" key + is not present for the node. If the "addrs" key is present and holds an + empty list, no defaulting is done. + This will default addresses for node2 and won't modify addresses for other + nodes (no addresses will be defined for node3): + nodes=[ + {"name": "node1", "addrs": ["node1-addr"]}, + {"name": "node2"}, + {"name": "node3", "addrs": []}, + ] + """ + ensure_live_env(env) # raises if env is not live + force = reports.codes.FORCE in force_flags + + transport_type = transport_type or "knet" + transport_options = transport_options or {} + link_list = link_list or [] + compression_options = compression_options or {} + crypto_options = crypto_options or {} + totem_options = totem_options or {} + quorum_options = quorum_options or {} + nodes = [normalize_dict(node, {"addrs"}) for node in nodes] + if ( + transport_type in corosync_constants.TRANSPORTS_KNET + and not crypto_options + ): + crypto_options = { + "cipher": "aes256", + "hash": "sha256", + } + + report_processor = env.report_processor + target_factory = env.get_node_target_factory() + + # Get targets for all nodes and report unknown (== not-authorized) nodes. + # If a node doesn't contain the 'name' key, validation of inputs reports it. + # That means we don't report missing names but cannot rely on them being + # present either. + ( + target_report_list, + target_list, + ) = target_factory.get_target_list_with_reports( + [node["name"] for node in nodes if "name" in node], + allow_skip=False, + ) + report_processor.report_list(target_report_list) + + # Use an address defined in known-hosts for each node with no addresses + # specified. This allows users not to specify node addresses at all which + # simplifies the whole cluster setup command / form significantly. + addrs_defaulter = get_addrs_defaulter( + report_processor, {target.label: target for target in target_list} + ) + nodes = [ + set_defaults_in_dict(node, {"addrs": addrs_defaulter}) for node in nodes + ] + + # Validate inputs. + report_processor.report_list( + _validate_create_corosync_conf( + cluster_name, + nodes, + transport_type, + transport_options, + link_list, + compression_options, + crypto_options, + totem_options, + quorum_options, + force, + ) + ) + + # Validate flags + wait_timeout = get_validated_wait_timeout(report_processor, wait, start) + + # Validate the nodes + com_cmd: AllSameDataMixin = GetHostInfo(report_processor) + com_cmd.set_targets(target_list) + report_processor.report_list( + host_check_cluster_setup( + run_com(env.get_node_communicator(), com_cmd), force + ) + ) + + # If there is an error reading the file, this will report it and exit + # safely before any change is made to the nodes. + sync_ssl_certs = is_ssl_cert_sync_enabled(report_processor) + + if report_processor.has_errors: + raise LibraryError() + + # Validation done. If errors occurred, an exception has been raised and we + # don't get below this line. + + # Destroy cluster on all nodes. + com_cmd = cluster.Destroy(env.report_processor) + com_cmd.set_targets(target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # Distribute auth tokens. + com_cmd = UpdateKnownHosts( + env.report_processor, + known_hosts_to_add=env.get_known_hosts( + [target.label for target in target_list] + ), + known_hosts_to_remove=[], + ) + com_cmd.set_targets(target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # TODO This should be in the file distribution call but so far we don't + # have a call which allows to save and delete files at the same time. + com_cmd = RemoveFilesWithoutForces( + env.report_processor, + {"pcsd settings": {"type": "pcsd_settings"}}, + ) + com_cmd.set_targets(target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + if not no_keys_sync: + # Distribute configuration files except corosync.conf. Sending + # corosync.conf serves as a "commit" as its presence on a node marks the + # node as a part of a cluster. + corosync_authkey = generate_binary_key( + random_bytes_count=settings.corosync_authkey_bytes + ) + pcmk_authkey = generate_binary_key( + random_bytes_count=settings.pacemaker_authkey_bytes + ) + actions = {} + actions.update( + node_communication_format.corosync_authkey_file(corosync_authkey) + ) + actions.update( + node_communication_format.pcmk_authkey_file(pcmk_authkey) + ) + com_cmd = DistributeFilesWithoutForces(env.report_processor, actions) + com_cmd.set_targets(target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # Distribute and reload pcsd SSL certificate + if sync_ssl_certs: + report_processor.report( + reports.ReportItem.info( + reports.messages.PcsdSslCertAndKeyDistributionStarted( + sorted([target.label for target in target_list]) + ) + ) + ) + # Local certificate and key cannot be used because the local node + # may not be a part of the new cluster at all. + ssl_key_raw = ssl.generate_key() + ssl_key = ssl.dump_key(ssl_key_raw) + ssl_cert = ssl.dump_cert( + ssl.generate_cert(ssl_key_raw, target_list[0].label) + ) + com_cmd = SendPcsdSslCertAndKey( + env.report_processor, ssl_cert, ssl_key + ) + com_cmd.set_targets(target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # Create and distribute corosync.conf. Once a node saves corosync.conf it + # is considered to be in a cluster. + # raises if corosync not valid + com_cmd = DistributeFilesWithoutForces( + env.report_processor, + node_communication_format.corosync_conf_file( + _create_corosync_conf( + cluster_name, + nodes, + transport_type, + transport_options, + link_list, + compression_options, + crypto_options, + totem_options, + quorum_options, + no_cluster_uuid, + ).config.export() + ), + ) + com_cmd.set_targets(target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + if env.report_processor.report( + reports.ReportItem.info(reports.messages.ClusterSetupSuccess()) + ).has_errors: + raise LibraryError() + + # Optionally enable and start cluster services. + if enable: + com_cmd = EnableCluster(env.report_processor) + com_cmd.set_targets(target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + if start: + start_cluster( + env.communicator_factory, + env.report_processor, + target_list, + wait_timeout=wait_timeout, + ) + + +def setup_local( # noqa: PLR0913 + env: LibraryEnvironment, + cluster_name: str, + nodes: Sequence[Mapping[str, Any]], + transport_type: Optional[str], + transport_options: Mapping[str, str], + link_list: Sequence[Mapping[str, str]], + compression_options: Mapping[str, str], + crypto_options: Mapping[str, str], + totem_options: Mapping[str, str], + quorum_options: Mapping[str, str], + no_cluster_uuid: bool = False, + force_flags: reports.types.ForceFlags = (), +) -> bytes: + """ + Return corosync.conf text based on specified parameters. + Raise LibraryError on any error. + + env + cluster_name -- name of a cluster to set up + nodes list -- list of dicts which represents node. + Supported keys are: name (required), addrs. See note bellow. + transport_type -- transport type of a cluster + transport_options -- transport specific options + link_list -- list of links, depends of transport_type + compression_options -- only available for knet transport. In + corosync.conf they are prefixed 'knet_compression_' + crypto_options -- only available for knet transport'. In corosync.conf + they are prefixed 'crypto_' + totem_options -- options of section 'totem' in corosync.conf + quorum_options -- options of section 'quorum' in corosync.conf + no_cluster_uuid -- if True, do not generate a unique cluster UUID into + the totem section of corosync.conf + force_flags -- list of flags codes + + The command is defaulting node addresses if they are not specified. The + defaulting is done for each node individually if and only if the "addrs" key + is not present for the node. If the "addrs" key is present and holds an + empty list, no defaulting is done. + This will default addresses for node2 and won't modify addresses for other + nodes (no addresses will be defined for node3): + nodes=[ + {"name": "node1", "addrs": ["node1-addr"]}, + {"name": "node2"}, + {"name": "node3", "addrs": []}, + ] + """ + # pylint: disable=too-many-arguments + # pylint: disable=too-many-locals + # pylint: disable=too-many-positional-arguments + force = reports.codes.FORCE in force_flags + + transport_type = transport_type or "knet" + nodes = [normalize_dict(node, {"addrs"}) for node in nodes] + if ( + transport_type in corosync_constants.TRANSPORTS_KNET + and not crypto_options + ): + crypto_options = { + "cipher": "aes256", + "hash": "sha256", + } + + report_processor = env.report_processor + target_factory = env.get_node_target_factory() + + # Get targets just for address defaulting, no need to report unknown nodes + _, target_list = target_factory.get_target_list_with_reports( + [node["name"] for node in nodes if "name" in node], + allow_skip=False, + ) + + # Use an address defined in known-hosts for each node with no addresses + # specified. This allows users not to specify node addresses at all which + # simplifies the whole cluster setup command / form significantly. + + # If there is no address for a node in known-hosts, use its name as the + # default address + addrs_defaulter = get_addrs_defaulter( + report_processor, + {target.label: target for target in target_list}, + default_to_name_if_no_target=True, + ) + nodes = [ + set_defaults_in_dict(node, {"addrs": addrs_defaulter}) for node in nodes + ] + + # Validate inputs. + if report_processor.report_list( + _validate_create_corosync_conf( + cluster_name, + nodes, + transport_type, + transport_options, + link_list, + compression_options, + crypto_options, + totem_options, + quorum_options, + force, + ) + ).has_errors: + raise LibraryError() + + # Validation done. If errors occurred, an exception has been raised and we + # don't get below this line. + + return ( + _create_corosync_conf( + cluster_name, + nodes, + transport_type, + transport_options, + link_list, + compression_options, + crypto_options, + totem_options, + quorum_options, + no_cluster_uuid, + ) + .config.export() + .encode("utf-8") + ) + + +def _validate_create_corosync_conf( # noqa: PLR0913 + cluster_name: str, + nodes: Sequence[Mapping[str, Any]], + transport_type: str, + transport_options: Mapping[str, str], + link_list: Sequence[Mapping[str, str]], + compression_options: Mapping[str, str], + crypto_options: Mapping[str, str], + totem_options: Mapping[str, str], + quorum_options: Mapping[str, str], + force: bool, +) -> reports.ReportItemList: + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments + + # Get IP version for node addresses validation. Defaults taken from man + # corosync.conf + ip_version = ( + corosync_constants.IP_VERSION_4 + if transport_type == "udp" + else corosync_constants.IP_VERSION_64 + ) + if ( + transport_options.get("ip_version") + in corosync_constants.IP_VERSION_VALUES + ): + ip_version = transport_options["ip_version"] + + report_list = [] + report_list += config_validators.create( + cluster_name, + nodes, + transport_type, + ip_version, + force_unresolvable=force, + force_cluster_name=force, + ) + max_node_addr_count = max((len(node["addrs"]) for node in nodes), default=0) + if transport_type in corosync_constants.TRANSPORTS_KNET: + report_list += config_validators.create_transport_knet( + transport_options, compression_options, crypto_options + ) + report_list += config_validators.create_link_list_knet( + link_list, max_node_addr_count + ) + + elif transport_type in corosync_constants.TRANSPORTS_UDP: + report_list += config_validators.create_transport_udp( + transport_options, compression_options, crypto_options + ) + report_list += config_validators.create_link_list_udp( + link_list, max_node_addr_count + ) + return ( + report_list + + config_validators.create_totem(totem_options) + # We are creating the config and we know there is no qdevice in it. + + config_validators.create_quorum_options(quorum_options, False) + ) + + +def _create_corosync_conf( # noqa: PLR0913 + cluster_name: str, + nodes: Sequence[Mapping[str, Any]], + transport_type: str, + transport_options: Mapping[str, str], + link_list: Sequence[Mapping[str, str]], + compression_options: Mapping[str, str], + crypto_options: Mapping[str, str], + totem_options: Mapping[str, str], + quorum_options: Mapping[str, str], + no_cluster_uuid: bool, +) -> config_facade.ConfigFacade: + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments + corosync_conf = config_facade.ConfigFacade.create( + cluster_name, nodes, transport_type + ) + corosync_conf.set_totem_options(totem_options) + corosync_conf.set_quorum_options(quorum_options) + corosync_conf.create_link_list(link_list) + corosync_conf.set_transport_options( + transport_options, + compression_options, + crypto_options, + ) + if not no_cluster_uuid: + corosync_conf.set_cluster_uuid(generate_uuid()) + + verify_corosync_conf(corosync_conf) # raises if corosync not valid + return corosync_conf diff --git a/pcs/lib/commands/cluster/setup_node.py b/pcs/lib/commands/cluster/setup_node.py new file mode 100644 index 000000000..3201ce399 --- /dev/null +++ b/pcs/lib/commands/cluster/setup_node.py @@ -0,0 +1,636 @@ +import os.path + +from pcs import settings +from pcs.common import file_type_codes, reports +from pcs.common.file import RawFileError +from pcs.common.node_communicator import HostNotFound +from pcs.common.tools import format_os_error +from pcs.lib import node_communication_format, sbd, validate +from pcs.lib.booth import sync as booth_sync +from pcs.lib.cib.resource.guest_node import find_node_list as get_guest_nodes +from pcs.lib.cib.resource.remote_node import find_node_list as get_remote_nodes +from pcs.lib.commands.cluster.setup_utils import ( + get_addrs_defaulter, + get_validated_wait_timeout, + host_check_cluster_setup, + is_ssl_cert_sync_enabled, + normalize_dict, + set_defaults_in_dict, + start_cluster, +) +from pcs.lib.commands.cluster.utils import ensure_live_env, verify_corosync_conf +from pcs.lib.communication.corosync import ( + CheckCorosyncOffline, + DistributeCorosyncConf, + ReloadCorosyncConf, +) +from pcs.lib.communication.nodes import ( + DistributeFilesWithoutForces, + EnableCluster, + GetHostInfo, + GetOnlineTargets, + SendPcsdSslCertAndKey, + UpdateKnownHosts, +) +from pcs.lib.communication.sbd import ( + CheckSbd, + DisableSbdService, + EnableSbdService, + SetSbdConfig, +) +from pcs.lib.communication.tools import AllSameDataMixin, run_and_raise +from pcs.lib.communication.tools import run as run_com +from pcs.lib.corosync import config_validators, qdevice_net +from pcs.lib.env import LibraryEnvironment +from pcs.lib.errors import LibraryError +from pcs.lib.node import get_existing_nodes_names +from pcs.lib.tools import environment_file_to_dict + + +def add_nodes( # noqa: PLR0912, PLR0915 + env: LibraryEnvironment, + nodes, + wait=False, + start=False, + enable=False, + no_watchdog_validation=False, + force_flags: reports.types.ForceFlags = (), +): + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + """ + Add specified nodes to the local cluster + Raise LibraryError on any error. + + env LibraryEnvironment + nodes list -- list of dicts which represents node. + Supported keys are: name (required), addrs (list), devices (list), + watchdog. See note below. + wait -- specifies if command should try to wait for cluster to start up. + Has no effect start is False. If set to False command will not wait for + cluster to start. If None command will wait for some default timeout. + If int wait set timeout to int value of seconds. + start bool -- if True start cluster when it is set up + enable bool -- if True enable cluster when it is set up + no_watchdog_validation bool -- if True do not validate specified watchdogs + on remote hosts + force_flags list -- list of flags codes + + The command is defaulting node addresses if they are not specified. The + defaulting is done for each node individually if and only if the "addrs" key + is not present for the node. If the "addrs" key is present and holds an + empty list, no defaulting is done. + This will default addresses for node2 and won't modify addresses for other + nodes (no addresses will be defined for node3): + nodes=[ + {"name": "node1", "addrs": ["node1-addr"]}, + {"name": "node2"}, + {"name": "node3", "addrs": []}, + ] + """ + ensure_live_env(env) # raises if env is not live + + force = reports.codes.FORCE in force_flags + skip_offline_nodes = reports.codes.SKIP_OFFLINE_NODES in force_flags + + report_processor = env.report_processor + target_factory = env.get_node_target_factory() + is_sbd_enabled = sbd.is_sbd_enabled(env.service_manager) + corosync_conf = env.get_corosync_conf() + corosync_node_options = {"name", "addrs"} + sbd_node_options = {"devices", "watchdog"} + + keys_to_normalize = {"addrs"} + if is_sbd_enabled: + keys_to_normalize |= sbd_node_options + new_nodes = [normalize_dict(node, keys_to_normalize) for node in nodes] + + # get targets for existing nodes + cluster_nodes_names, nodes_report_list = get_existing_nodes_names( + corosync_conf, + # Pcs is unable to communicate with nodes missing names. It cannot send + # new corosync.conf to them. That might break the cluster. Hence we + # error out. + error_on_missing_name=True, + ) + report_processor.report_list(nodes_report_list) + ( + target_report_list, + cluster_nodes_target_list, + ) = target_factory.get_target_list_with_reports( + cluster_nodes_names, + skip_non_existing=skip_offline_nodes, + ) + report_processor.report_list(target_report_list) + + # get a target for qnetd if needed + if corosync_conf.get_quorum_device_model() == "net": + ( + qdevice_model_options, + _, + _, + ) = corosync_conf.get_quorum_device_settings() + try: + qnetd_target = target_factory.get_target( + qdevice_model_options["host"] + ) + except HostNotFound: + report_processor.report( + reports.ReportItem.error( + reports.messages.HostNotFound( + [qdevice_model_options["host"]] + ) + ) + ) + + # Get targets for new nodes and report unknown (== not-authorized) nodes. + # If a node doesn't contain the 'name' key, validation of inputs reports it. + # That means we don't report missing names but cannot rely on them being + # present either. + ( + target_report_list, + new_nodes_target_list, + ) = target_factory.get_target_list_with_reports( + [node["name"] for node in new_nodes if "name" in node], + allow_skip=False, + ) + report_processor.report_list(target_report_list) + + # Set default values for not-specified node options. + # Use an address defined in known-hosts for each node with no addresses + # specified. This allows users not to specify node addresses at all which + # simplifies the whole node add command / form significantly. + new_nodes_target_dict = { + target.label: target for target in new_nodes_target_list + } + addrs_defaulter = get_addrs_defaulter( + report_processor, new_nodes_target_dict + ) + new_nodes_defaulters = {"addrs": addrs_defaulter} + if is_sbd_enabled: + watchdog_defaulter = _get_watchdog_defaulter( + report_processor, new_nodes_target_dict + ) + new_nodes_defaulters["devices"] = lambda _: [] + new_nodes_defaulters["watchdog"] = watchdog_defaulter + new_nodes = [ + set_defaults_in_dict(node, new_nodes_defaulters) for node in new_nodes + ] + new_nodes_dict = { + node["name"]: node for node in new_nodes if "name" in node + } + + # Validate inputs - node options names + # We do not want to make corosync validators know about SBD options and + # vice versa. Therefore corosync and SBD validators get only valid corosync + # and SBD options respectively, and we need to check for any surplus + # options here. + report_processor.report_list( + validate.NamesIn( + corosync_node_options | sbd_node_options, option_type="node" + ).validate( + { + # Get a dict containing options of all nodes. Values don't + # matter for validate.NamesIn validator. + option_name: "" + for node_option_names in [node.keys() for node in new_nodes] + for option_name in node_option_names + } + ) + ) + + # Validate inputs - corosync part + try: + cib = env.get_cib() + cib_nodes = get_remote_nodes(cib) + get_guest_nodes(cib) + except LibraryError: + cib_nodes = [] + report_processor.report( + reports.ReportItem( + reports.item.get_severity(reports.codes.FORCE, force), + reports.messages.CibLoadErrorGetNodesForValidation(), + ) + ) + # corosync validator rejects non-corosync keys + new_nodes_corosync = [ + {key: node[key] for key in corosync_node_options if key in node} + for node in new_nodes + ] + report_processor.report_list( + config_validators.add_nodes( + new_nodes_corosync, + corosync_conf.get_nodes(), + cib_nodes, + force_unresolvable=force, + ) + ) + + # Validate inputs - SBD part + if is_sbd_enabled: + report_processor.report_list( + sbd.validate_new_nodes_devices( + { + node["name"]: node["devices"] + for node in new_nodes + if "name" in node + } + ) + ) + else: + for node in new_nodes: + sbd_options = sbd_node_options.intersection(node.keys()) + if sbd_options and "name" in node: + report_processor.report( + reports.ReportItem.error( + reports.messages.SbdNotUsedCannotSetSbdOptions( + sorted(sbd_options), node["name"] + ) + ) + ) + + # Validate inputs - flags part + wait_timeout = get_validated_wait_timeout(report_processor, wait, start) + + # Get online cluster nodes + # This is the only call in which we accept skip_offline_nodes option for the + # cluster nodes. In all the other actions we communicate only with the + # online nodes. This allows us to simplify code as any communication issue + # is considered an error, ends the command processing and is not possible + # to skip it by skip_offline_nodes. We do not have to care about a situation + # when a communication command cannot connect to some nodes and then the + # next command can connect but fails due to the previous one did not + # succeed. + online_cluster_target_list = [] + if cluster_nodes_target_list: + com_cmd: AllSameDataMixin = GetOnlineTargets( + report_processor, + ignore_offline_targets=skip_offline_nodes, + ) + com_cmd.set_targets(cluster_nodes_target_list) + online_cluster_target_list = run_com( + env.get_node_communicator(), com_cmd + ) + offline_cluster_target_list = [ + target + for target in cluster_nodes_target_list + if target not in online_cluster_target_list + ] + if not online_cluster_target_list: + report_processor.report( + reports.ReportItem.error( + reports.messages.UnableToPerformOperationOnAnyNode() + ) + ) + elif offline_cluster_target_list and skip_offline_nodes: + # TODO: report (warn) how to fix offline nodes when they come online + # report_processor.report(None) + pass + + # Validate existing cluster nodes status + atb_has_to_be_enabled = sbd.atb_has_to_be_enabled( + env.service_manager, corosync_conf, len(new_nodes) + ) + if atb_has_to_be_enabled: + if online_cluster_target_list: + com_cmd = CheckCorosyncOffline( + report_processor, allow_skip_offline=False + ) + com_cmd.set_targets(online_cluster_target_list) + cluster_running_target_list = run_com( + env.get_node_communicator(), com_cmd + ) + if cluster_running_target_list: + report_processor.report( + reports.ReportItem.error( + reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbdClusterIsRunning() + ) + ) + else: + report_processor.report( + reports.ReportItem.warning( + reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbd() + ) + ) + + # Validate new nodes. All new nodes have to be online. + com_cmd = GetHostInfo(report_processor) + com_cmd.set_targets(new_nodes_target_list) + report_processor.report_list( + host_check_cluster_setup( + run_com(env.get_node_communicator(), com_cmd), + force, + # version of services may not be the same across the existing + # cluster nodes, so it's not easy to make this check properly + check_services_versions=False, + ) + ) + + # Validate SBD on new nodes + if is_sbd_enabled: + if no_watchdog_validation: + report_processor.report( + reports.ReportItem.warning( + reports.messages.SbdWatchdogValidationInactive() + ) + ) + com_cmd_sbd = CheckSbd(report_processor) + for new_node_target in new_nodes_target_list: + new_node = new_nodes_dict[new_node_target.label] + # Do not send watchdog if validation is turned off. Listing of + # available watchdogs in pcsd may restart the machine in some + # corner cases. + com_cmd_sbd.add_request( + new_node_target, + watchdog="" if no_watchdog_validation else new_node["watchdog"], + device_list=new_node["devices"], + ) + run_com(env.get_node_communicator(), com_cmd_sbd) + + # If there is an error reading the file, this will report it and exit + # safely before any change is made to the nodes. + sync_ssl_certs = is_ssl_cert_sync_enabled(report_processor) + + if report_processor.has_errors: + raise LibraryError() + + # Validation done. If errors occurred, an exception has been raised and we + # don't get below this line. + + # First set up everything else than corosync. Once the new nodes are present + # in corosync.conf, they're considered part of a cluster and the node add + # command cannot be run again. So we need to minimize the amount of actions + # (and therefore possible failures) after adding the nodes to corosync. + + # distribute auth tokens of all cluster nodes (including the new ones) to + # all new nodes + com_cmd = UpdateKnownHosts( + env.report_processor, + known_hosts_to_add=env.get_known_hosts( + cluster_nodes_names + list(new_nodes_dict.keys()) + ), + known_hosts_to_remove=[], + ) + com_cmd.set_targets(new_nodes_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # qdevice setup + if corosync_conf.get_quorum_device_model() == "net": + qdevice_net.set_up_client_certificates( + env.cmd_runner(), + env.report_processor, + env.communicator_factory, + qnetd_target, + corosync_conf.get_cluster_name(), + new_nodes_target_list, + # we don't want to allow skipping offline nodes which are being + # added, otherwise qdevice will not work properly + skip_offline_nodes=False, + allow_skip_offline=False, + ) + + # sbd setup + if is_sbd_enabled: + sbd_cfg = environment_file_to_dict(sbd.get_local_sbd_config()) + + com_cmd_sbd_cfg = SetSbdConfig(env.report_processor) + for new_node_target in new_nodes_target_list: + new_node = new_nodes_dict[new_node_target.label] + com_cmd_sbd_cfg.add_request( + new_node_target, + sbd.create_sbd_config( + sbd_cfg, + new_node["name"], + watchdog=new_node["watchdog"], + device_list=new_node["devices"], + ), + ) + run_and_raise(env.get_node_communicator(), com_cmd_sbd_cfg) + + com_cmd = EnableSbdService(env.report_processor) + com_cmd.set_targets(new_nodes_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + else: + com_cmd = DisableSbdService(env.report_processor) + com_cmd.set_targets(new_nodes_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # booth setup + booth_sync.send_all_config_to_node( + env.get_node_communicator(), + env.report_processor, + new_nodes_target_list, + rewrite_existing=force, + skip_wrong_config=force, + ) + + # distribute corosync and pacemaker authkeys and other config files + files_action = {} + severity = reports.item.get_severity(reports.codes.FORCE, force) + if os.path.isfile(settings.corosync_authkey_file): + try: + with open( + settings.corosync_authkey_file, "rb" + ) as corosync_authkey_file: + files_action.update( + node_communication_format.corosync_authkey_file( + corosync_authkey_file.read() + ) + ) + except OSError as e: + report_processor.report( + reports.ReportItem( + severity, + reports.messages.FileIoError( + file_type_codes.COROSYNC_AUTHKEY, + RawFileError.ACTION_READ, + format_os_error(e), + file_path=settings.corosync_authkey_file, + ), + ) + ) + + if os.path.isfile(settings.pacemaker_authkey_file): + try: + with open( + settings.pacemaker_authkey_file, "rb" + ) as pcmk_authkey_file: + files_action.update( + node_communication_format.pcmk_authkey_file( + pcmk_authkey_file.read() + ) + ) + except OSError as e: + report_processor.report( + reports.ReportItem( + severity, + reports.messages.FileIoError( + file_type_codes.PACEMAKER_AUTHKEY, + RawFileError.ACTION_READ, + format_os_error(e), + file_path=settings.pacemaker_authkey_file, + ), + ) + ) + + if os.path.isfile(settings.pcsd_dr_config_location): + try: + with open( + settings.pcsd_dr_config_location, "rb" + ) as pcs_dr_config_file: + files_action.update( + node_communication_format.pcs_dr_config_file( + pcs_dr_config_file.read() + ) + ) + except OSError as e: + report_processor.report( + reports.ReportItem( + severity, + reports.messages.FileIoError( + file_type_codes.PCS_DR_CONFIG, + RawFileError.ACTION_READ, + format_os_error(e), + file_path=settings.pcsd_dr_config_location, + ), + ) + ) + + # pcs_settings.conf was previously synced using pcsdcli send_local_configs. + # This has been changed temporarily until new system for distribution and + # synchronization of configs will be introduced. + if os.path.isfile(settings.pcsd_settings_conf_location): + try: + with open( + settings.pcsd_settings_conf_location, "r" + ) as pcs_settings_conf_file: + files_action.update( + node_communication_format.pcs_settings_conf_file( + pcs_settings_conf_file.read() + ) + ) + except OSError as e: + report_processor.report( + reports.ReportItem( + severity, + reports.messages.FileIoError( + file_type_codes.PCS_SETTINGS_CONF, + RawFileError.ACTION_READ, + format_os_error(e), + file_path=settings.pcsd_settings_conf_location, + ), + ) + ) + + # stop here if one of the files could not be loaded and it was not forced + if report_processor.has_errors: + raise LibraryError() + + if files_action: + com_cmd = DistributeFilesWithoutForces( + env.report_processor, files_action + ) + com_cmd.set_targets(new_nodes_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # Distribute and reload pcsd SSL certificate + if sync_ssl_certs: + report_processor.report( + reports.ReportItem.info( + reports.messages.PcsdSslCertAndKeyDistributionStarted( + sorted([target.label for target in new_nodes_target_list]) + ) + ) + ) + + try: + with open(settings.pcsd_cert_location, "r") as file: + ssl_cert = file.read() + except OSError as e: + report_processor.report( + reports.ReportItem.error( + reports.messages.FileIoError( + file_type_codes.PCSD_SSL_CERT, + RawFileError.ACTION_READ, + format_os_error(e), + file_path=settings.pcsd_cert_location, + ) + ) + ) + try: + with open(settings.pcsd_key_location, "r") as file: + ssl_key = file.read() + except OSError as e: + report_processor.report( + reports.ReportItem.error( + reports.messages.FileIoError( + file_type_codes.PCSD_SSL_KEY, + RawFileError.ACTION_READ, + format_os_error(e), + file_path=settings.pcsd_key_location, + ) + ) + ) + if report_processor.has_errors: + raise LibraryError() + + com_cmd = SendPcsdSslCertAndKey(env.report_processor, ssl_cert, ssl_key) + com_cmd.set_targets(new_nodes_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # When corosync >= 2 is in use, the procedure for adding a node is: + # 1. add the new node to corosync.conf on all existing nodes + # 2. reload corosync.conf before the new node is started + # 3. start the new node + # If done otherwise, membership gets broken and qdevice hangs. Cluster + # will recover after a minute or so but still it's a wrong way. + + corosync_conf.add_nodes(new_nodes_corosync) + if atb_has_to_be_enabled: + corosync_conf.set_quorum_options(dict(auto_tie_breaker="1")) + + # TODO why this does not use push_corosync_conf? + verify_corosync_conf(corosync_conf) # raises if corosync not valid + com_cmd = DistributeCorosyncConf( + env.report_processor, + corosync_conf.config.export(), + allow_skip_offline=False, + ) + com_cmd.set_targets(online_cluster_target_list + new_nodes_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + com_cmd = ReloadCorosyncConf(env.report_processor) + com_cmd.set_targets(online_cluster_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + + # Optionally enable and start cluster services. + if enable: + com_cmd = EnableCluster(env.report_processor) + com_cmd.set_targets(new_nodes_target_list) + run_and_raise(env.get_node_communicator(), com_cmd) + if start: + start_cluster( + env.communicator_factory, + env.report_processor, + new_nodes_target_list, + wait_timeout=wait_timeout, + ) + + +def _get_watchdog_defaulter( + report_processor: reports.ReportProcessor, targets_dict +): + del targets_dict + + def defaulter(node): + report_processor.report( + reports.ReportItem.info( + reports.messages.UsingDefaultWatchdog( + settings.sbd_watchdog_default, + node["name"], + ) + ) + ) + return settings.sbd_watchdog_default + + return defaulter diff --git a/pcs/lib/commands/cluster/setup_utils.py b/pcs/lib/commands/cluster/setup_utils.py new file mode 100644 index 000000000..581cc1e8c --- /dev/null +++ b/pcs/lib/commands/cluster/setup_utils.py @@ -0,0 +1,277 @@ +import math +import os.path +import time + +from pcs import settings +from pcs.common import file_type_codes, reports +from pcs.common.file import RawFileError +from pcs.common.tools import format_os_error +from pcs.lib.communication.nodes import CheckPacemakerStarted, StartCluster +from pcs.lib.communication.tools import run as run_com +from pcs.lib.communication.tools import run_and_raise +from pcs.lib.errors import LibraryError +from pcs.lib.pacemaker.values import get_valid_timeout_seconds +from pcs.lib.tools import environment_file_to_dict + + +def start_cluster( + communicator_factory, + report_processor: reports.ReportProcessor, + target_list, + wait_timeout=False, +) -> None: + # Large clusters take longer time to start up. So we make the timeout + # longer for each 8 nodes: + # 1 - 8 nodes: 1 * timeout + # 9 - 16 nodes: 2 * timeout + # 17 - 24 nodes: 3 * timeout + # and so on ... + # Users can override this and set their own timeout by specifying + # the --request-timeout option. + timeout = int( + settings.default_request_timeout * math.ceil(len(target_list) / 8.0) + ) + com_cmd = StartCluster(report_processor) + com_cmd.set_targets(target_list) + run_and_raise( + communicator_factory.get_communicator(request_timeout=timeout), com_cmd + ) + if wait_timeout is not False: + report_processor.report_list( + _wait_for_pacemaker_to_start( + communicator_factory.get_communicator(), + report_processor, + target_list, + # wait_timeout is either None or a timeout + timeout=wait_timeout, + ) + ) + if report_processor.has_errors: + raise LibraryError() + + +def _wait_for_pacemaker_to_start( + node_communicator, + report_processor: reports.ReportProcessor, + target_list, + timeout=None, +): + timeout = 60 * 15 if timeout is None else timeout + interval = 2 + stop_at = time.time() + timeout + report_processor.report( + reports.ReportItem.info( + reports.messages.WaitForNodeStartupStarted( + sorted([target.label for target in target_list]) + ) + ) + ) + error_report_list = [] + has_errors = False + while target_list: + if time.time() > stop_at: + error_report_list.append( + reports.ReportItem.error( + reports.messages.WaitForNodeStartupTimedOut() + ) + ) + break + time.sleep(interval) + com_cmd = CheckPacemakerStarted(report_processor) + com_cmd.set_targets(target_list) + target_list = run_com(node_communicator, com_cmd) + has_errors = has_errors or com_cmd.has_errors + + if error_report_list or has_errors: + error_report_list.append( + reports.ReportItem.error(reports.messages.WaitForNodeStartupError()) + ) + return error_report_list + + +def host_check_cluster_setup( + host_info_dict, force, check_services_versions=True +): + # pylint: disable=too-many-locals + report_list = [] + # We only care about services which matter for creating a cluster. It does + # not make sense to check e.g. booth when a) it will never be used b) it + # will be used in a year - which means we should do the check in a year. + service_version_dict = { + "pacemaker": {}, + "corosync": {}, + "pcsd": {}, + } + required_service_list = ["pacemaker", "corosync"] + required_as_stopped_service_list = required_service_list + [ + "pacemaker_remote" + ] + severity = reports.item.get_severity(reports.codes.FORCE, force) + cluster_exists_on_nodes = False + for host_name, host_info in host_info_dict.items(): + try: + services = host_info["services"] + if check_services_versions: + for service, version_dict in service_version_dict.items(): + version_dict[host_name] = services[service]["version"] + missing_service_list = [ + service + for service in required_service_list + if not services[service]["installed"] + ] + if missing_service_list: + report_list.append( + reports.ReportItem.error( + reports.messages.ServiceNotInstalled( + host_name, sorted(missing_service_list) + ) + ) + ) + cannot_be_running_service_list = [ + service + for service in required_as_stopped_service_list + if service in services and services[service]["running"] + ] + if cannot_be_running_service_list: + cluster_exists_on_nodes = True + report_list.append( + reports.ReportItem( + severity=severity, + message=reports.messages.HostAlreadyInClusterServices( + host_name, + sorted(cannot_be_running_service_list), + ), + ) + ) + if host_info["cluster_configuration_exists"]: + cluster_exists_on_nodes = True + report_list.append( + reports.ReportItem( + severity=severity, + message=reports.messages.HostAlreadyInClusterConfig( + host_name, + ), + ) + ) + except KeyError: + report_list.append( + reports.ReportItem.error( + reports.messages.InvalidResponseFormat(host_name) + ) + ) + + if check_services_versions: + for service, version_dict in service_version_dict.items(): + report_list.extend( + _check_for_not_matching_service_versions(service, version_dict) + ) + + if cluster_exists_on_nodes and not force: + # This is always a forceable error + report_list.append( + reports.ReportItem( + severity=reports.item.ReportItemSeverity.error( + reports.codes.FORCE + ), + message=reports.messages.ClusterWillBeDestroyed(), + ) + ) + return report_list + + +def _check_for_not_matching_service_versions(service, service_version_dict): + if len(set(service_version_dict.values())) <= 1: + return [] + return [ + reports.ReportItem.error( + reports.messages.ServiceVersionMismatch( + service, service_version_dict + ) + ) + ] + + +def normalize_dict(input_dict, required_keys): + normalized = dict(input_dict) + for key in required_keys: + if key not in normalized: + normalized[key] = None + return normalized + + +def set_defaults_in_dict(input_dict, defaults): + completed = dict(input_dict) + for key, factory in defaults.items(): + if completed[key] is None: + completed[key] = factory(input_dict) + return completed + + +def get_addrs_defaulter( + report_processor: reports.ReportProcessor, + targets_dict, + default_to_name_if_no_target: bool = False, +): + def defaulter(node): + if "name" not in node: + return [] + address_for_use = None + address_source = None + target = targets_dict.get(node["name"]) + if target: + address_for_use = target.first_addr + address_source = reports.const.DEFAULT_ADDRESS_SOURCE_KNOWN_HOSTS + elif default_to_name_if_no_target: + address_for_use = node["name"] + address_source = reports.const.DEFAULT_ADDRESS_SOURCE_HOST_NAME + if address_for_use: + report_processor.report( + reports.ReportItem.info( + reports.messages.UsingDefaultAddressForHost( + node["name"], address_for_use, address_source + ) + ) + ) + return [address_for_use] + return [] + + return defaulter + + +def get_validated_wait_timeout(report_processor, wait, start): + try: + if wait is False: + return False + if not start: + report_processor.report( + reports.ReportItem.error( + reports.messages.WaitForNodeStartupWithoutStart() + ) + ) + return get_valid_timeout_seconds(wait) + except LibraryError as e: + report_processor.report_list(e.args) + return None + + +def is_ssl_cert_sync_enabled(report_processor: reports.ReportProcessor) -> bool: + try: + if os.path.isfile(settings.pcsd_config): + with open(settings.pcsd_config, "r") as cfg_file: + cfg = environment_file_to_dict(cfg_file.read()) + return ( + cfg.get("PCSD_SSL_CERT_SYNC_ENABLED", "false").lower() + == "true" + ) + except OSError as e: + report_processor.report( + reports.ReportItem.error( + reports.messages.FileIoError( + file_type_codes.PCSD_ENVIRONMENT_CONFIG, + RawFileError.ACTION_READ, + format_os_error(e), + file_path=settings.pcsd_config, + ) + ) + ) + return False diff --git a/pcs/lib/commands/cluster/utils.py b/pcs/lib/commands/cluster/utils.py new file mode 100644 index 000000000..eefc88cb0 --- /dev/null +++ b/pcs/lib/commands/cluster/utils.py @@ -0,0 +1,40 @@ +from pcs.common import file_type_codes, reports +from pcs.lib.corosync import config_parser +from pcs.lib.corosync.config_facade import ConfigFacade +from pcs.lib.env import LibraryEnvironment +from pcs.lib.errors import LibraryError + + +def ensure_live_env(env: LibraryEnvironment) -> None: + not_live = [] + if not env.is_cib_live: + not_live.append(file_type_codes.CIB) + if not env.is_corosync_conf_live: + not_live.append(file_type_codes.COROSYNC_CONF) + if not_live: + raise LibraryError( + reports.ReportItem.error( + reports.messages.LiveEnvironmentRequired(not_live) + ) + ) + + +def verify_corosync_conf(corosync_conf_facade: ConfigFacade) -> None: + # This is done in pcs.lib.env.LibraryEnvironment.push_corosync_conf + # usually. But there are special cases here which use custom corosync.conf + # pushing so the check must be done individually. + ( + bad_sections, + bad_attr_names, + bad_attr_values, + ) = config_parser.verify_section(corosync_conf_facade.config) + if bad_sections or bad_attr_names or bad_attr_values: + raise LibraryError( + reports.ReportItem.error( + reports.messages.CorosyncConfigCannotSaveInvalidNamesValues( + bad_sections, + bad_attr_names, + bad_attr_values, + ) + ) + ) diff --git a/pcs/lib/commands/cluster_property.py b/pcs/lib/commands/cluster_property.py index a25858cc4..ef9aaf202 100644 --- a/pcs/lib/commands/cluster_property.py +++ b/pcs/lib/commands/cluster_property.py @@ -1,12 +1,10 @@ -from typing import ( - Collection, - Mapping, - Union, -) +from typing import Mapping, Union +from pcs import settings from pcs.common import reports from pcs.common.pacemaker.cluster_property import ClusterPropertyMetadataDto from pcs.common.pacemaker.nvset import ListCibNvsetDto +from pcs.common.str_tools import join_multilines from pcs.common.types import StringSequence from pcs.lib import cluster_property from pcs.lib.cib import ( @@ -20,13 +18,14 @@ ) from pcs.lib.env import LibraryEnvironment from pcs.lib.errors import LibraryError +from pcs.lib.pacemaker.live import get_cib_file_runner_env, has_cib_xml from pcs.lib.resource_agent import ( ResourceAgentError, ResourceAgentFacade, ResourceAgentMetadata, + resource_agent_error_to_report_item, ) from pcs.lib.resource_agent import const as ra_const -from pcs.lib.resource_agent import resource_agent_error_to_report_item from pcs.lib.resource_agent.facade import ResourceAgentFacadeFactory @@ -124,7 +123,7 @@ def get_cluster_properties_definition_legacy( def set_properties( env: LibraryEnvironment, cluster_properties: Mapping[str, str], - force_flags: Collection[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: """ Set specified pacemaker cluster properties, remove those with empty values. @@ -228,3 +227,36 @@ def get_properties_metadata( ], readonly_properties=cluster_property.READONLY_CLUSTER_PROPERTY_LIST, ) + + +def remove_cluster_name(env: LibraryEnvironment) -> None: + """ + Remove cluster-name property from CIB on local node. The cluster has to be + stopped and the property is removed directly from the CIB file. + """ + if env.service_manager.is_running("pacemaker"): + env.report_processor.report( + reports.ReportItem.error(reports.messages.PacemakerRunning()) + ) + raise LibraryError() + + if not has_cib_xml(): + env.report_processor.report( + reports.ReportItem.error(reports.messages.CibXmlMissing()) + ) + raise LibraryError() + + xpath = "/cib/configuration/crm_config/cluster_property_set/nvpair[@name='cluster-name']" + stdout, stderr, retval = env.cmd_runner().run( + [settings.cibadmin_exec, "--delete-all", "--force", f"--xpath={xpath}"], + env_extend=get_cib_file_runner_env(), + ) + if retval != 0: + env.report_processor.report( + reports.ReportItem.error( + reports.messages.CibClusterNameRemovalFailed( + reason=join_multilines([stderr, stdout]) + ) + ) + ) + raise LibraryError() diff --git a/pcs/lib/commands/dr.py b/pcs/lib/commands/dr.py index 948b7df02..881c46167 100644 --- a/pcs/lib/commands/dr.py +++ b/pcs/lib/commands/dr.py @@ -1,12 +1,4 @@ -from typing import ( - Any, - Container, - Iterable, - List, - Mapping, - Tuple, - cast, -) +from typing import Any, Iterable, List, Mapping, Tuple, cast from pcs.common import ( file_type_codes, @@ -311,7 +303,7 @@ def _load_dr_config( def destroy( env: LibraryEnvironment, - force_flags: Container[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: """ Destroy disaster-recovery configuration on all sites diff --git a/pcs/lib/commands/fencing_topology.py b/pcs/lib/commands/fencing_topology.py index ff339d4f8..19924bc12 100644 --- a/pcs/lib/commands/fencing_topology.py +++ b/pcs/lib/commands/fencing_topology.py @@ -1,13 +1,18 @@ -from typing import Optional +from typing import ( + Any, + Optional, +) from pcs.common import reports as report -from pcs.common.fencing_topology import TARGET_TYPE_NODE -from pcs.common.types import StringCollection -from pcs.lib.cib import fencing_topology as cib_fencing_topology -from pcs.lib.cib.tools import ( - get_fencing_topology, - get_resources, +from pcs.common.fencing_topology import ( + TARGET_TYPE_NODE, + FencingTargetType, + FencingTargetValue, ) +from pcs.common.pacemaker.fencing_topology import CibFencingTopologyDto +from pcs.common.types import StringSequence +from pcs.lib.cib import fencing_topology as cib_fencing_topology +from pcs.lib.cib.tools import get_fencing_topology from pcs.lib.env import LibraryEnvironment from pcs.lib.errors import LibraryError from pcs.lib.pacemaker.state import ClusterState @@ -15,34 +20,36 @@ def add_level( lib_env: LibraryEnvironment, - level, - target_type, - target_value, - devices, - force_device=False, - force_node=False, -): + level: str, + target_type: FencingTargetType, + target_value: FencingTargetValue, + devices: StringSequence, + level_id: Optional[str] = None, + force_device: bool = False, + force_node: bool = False, +) -> None: """ Validate and add a new fencing level - LibraryEnvironment lib_env -- environment - int|string level -- level (index) of the new fencing level - constant target_type -- the new fencing level target value type - mixed target_value -- the new fencing level target value - Iterable devices -- list of stonith devices for the new fencing level - bool force_device -- continue even if a stonith device does not exist - bool force_node -- continue even if a node (target) does not exist + lib_env -- environment + level -- level (index) of the new fencing level + target_type -- the new fencing level target value type + target_value -- the new fencing level target value + devices -- list of stonith devices for the new fencing level + level_id -- user specified id for the level element + force_device -- continue even if a stonith device does not exist + force_node -- continue even if a node (target) does not exist """ cib = lib_env.get_cib() cib_fencing_topology.add_level( lib_env.report_processor, - get_fencing_topology(cib), - get_resources(cib), + cib, level, target_type, target_value, devices, ClusterState(lib_env.get_cluster_state()).node_section.nodes, + level_id, force_device, force_node, ) @@ -51,23 +58,36 @@ def add_level( lib_env.push_cib() -def get_config(lib_env: LibraryEnvironment): +# DEPRECATED, use get_config_dto +def get_config(lib_env: LibraryEnvironment) -> list[dict[str, Any]]: """ Get fencing levels configuration. Return a list of levels where each level is a dict with keys: target_type, - target_value. level and devices. Devices is a list of stonith device ids. + target_value, level and devices. Devices is a list of stonith device ids. - LibraryEnvironment lib_env -- environment + lib_env -- environment """ cib = lib_env.get_cib() return cib_fencing_topology.export(get_fencing_topology(cib)) -def remove_all_levels(lib_env: LibraryEnvironment): +def get_config_dto(env: LibraryEnvironment) -> CibFencingTopologyDto: + """ + Get fencing level configuration. + + env -- library environment + """ + return cib_fencing_topology.fencing_topology_el_to_dto( + get_fencing_topology(env.get_cib()) + ) + + +def remove_all_levels(lib_env: LibraryEnvironment) -> None: """ Remove all fencing levels - LibraryEnvironment lib_env -- environment + + lib_env -- environment """ cib_fencing_topology.remove_all_levels( get_fencing_topology(lib_env.get_cib()) @@ -77,23 +97,22 @@ def remove_all_levels(lib_env: LibraryEnvironment): def remove_levels_by_params( lib_env: LibraryEnvironment, - level=None, - # TODO create a special type, so that it cannot accept any string - target_type: Optional[str] = None, - target_value=None, - devices: Optional[StringCollection] = None, + level: Optional[str] = None, + target_type: Optional[FencingTargetType] = None, + target_value: Optional[FencingTargetValue] = None, + devices: Optional[StringSequence] = None, # TODO remove, deprecated backward compatibility layer ignore_if_missing: bool = False, # TODO remove, deprecated backward compatibility layer target_may_be_a_device: bool = False, -): +) -> None: """ Remove specified fencing level(s). - LibraryEnvironment lib_env -- environment - int|string level -- level (index) of the fencing level to remove + lib_env -- environment + level -- level (index) of the fencing level to remove target_type -- the removed fencing level target value type - mixed target_value -- the removed fencing level target value + target_value -- the removed fencing level target value devices -- list of stonith devices of the removed fencing level ignore_if_missing -- when True, do not report if level not found target_may_be_a_device -- enables backward compatibility mode for old CLI @@ -151,7 +170,7 @@ def remove_levels_by_params( level, None, None, - target_and_devices, + target_and_devices, # type: ignore ignore_if_missing, validate_device_ids=(not target_may_be_a_device), ) @@ -165,17 +184,16 @@ def remove_levels_by_params( raise LibraryError() -def verify(lib_env: LibraryEnvironment): +def verify(lib_env: LibraryEnvironment) -> None: """ Check if all cluster nodes and stonith devices used in fencing levels exist - LibraryEnvironment lib_env -- environment + lib_env -- environment """ cib = lib_env.get_cib() lib_env.report_processor.report_list( cib_fencing_topology.verify( - get_fencing_topology(cib), - get_resources(cib), + cib, ClusterState(lib_env.get_cluster_state()).node_section.nodes, ) ) diff --git a/pcs/lib/commands/node.py b/pcs/lib/commands/node.py index da27fa3a4..cf58ea6da 100644 --- a/pcs/lib/commands/node.py +++ b/pcs/lib/commands/node.py @@ -1,13 +1,13 @@ from contextlib import contextmanager from pcs.common import reports +from pcs.common.pacemaker.node import CibNodeListDto from pcs.common.reports.item import ReportItem +from pcs.lib.cib import node from pcs.lib.cib.node import update_node_instance_attrs -from pcs.lib.cib.tools import IdProvider -from pcs.lib.env import ( - LibraryEnvironment, - WaitType, -) +from pcs.lib.cib.rule.in_effect import get_rule_evaluator +from pcs.lib.cib.tools import IdProvider, get_nodes +from pcs.lib.env import LibraryEnvironment, WaitType from pcs.lib.errors import LibraryError from pcs.lib.pacemaker.live import get_local_node_name from pcs.lib.pacemaker.state import ClusterState @@ -160,12 +160,11 @@ def _set_instance_attrs_node_list( ): with cib_runner_nodes(lib_env, wait) as (cib, dummy_runner, state_nodes): known_nodes = [node.attrs.name for node in state_nodes] - report_list = [] - for node in node_names: - if node not in known_nodes: - report_list.append( - ReportItem.error(reports.messages.NodeNotFound(node)) - ) + report_list = [ + ReportItem.error(reports.messages.NodeNotFound(node)) + for node in node_names + if node not in known_nodes + ] if report_list: raise LibraryError(*report_list) @@ -183,3 +182,18 @@ def _set_instance_attrs_all_nodes( update_node_instance_attrs( cib, IdProvider(cib), node, attrs, state_nodes=state_nodes ) + + +def get_config_dto( + lib_env: LibraryEnvironment, evaluate_expired: bool = False +) -> CibNodeListDto: + cib = lib_env.get_cib() + rule_in_effect_eval = get_rule_evaluator( + cib, lib_env.cmd_runner(), lib_env.report_processor, evaluate_expired + ) + return CibNodeListDto( + nodes=[ + node.node_el_to_dto(node_el, rule_eval=rule_in_effect_eval) + for node_el in node.get_all_node_elements(get_nodes(cib)) + ] + ) diff --git a/pcs/lib/commands/remote_node.py b/pcs/lib/commands/remote_node.py index ace07730f..83865ea9f 100644 --- a/pcs/lib/commands/remote_node.py +++ b/pcs/lib/commands/remote_node.py @@ -1,8 +1,6 @@ -from typing import ( - Iterable, - Mapping, - Optional, -) +from typing import TYPE_CHECKING, Callable, Iterable, Mapping, Optional + +from lxml.etree import _Element from pcs import settings from pcs.common import reports @@ -12,6 +10,10 @@ ReportProcessor, ) from pcs.lib import node_communication_format +from pcs.lib.cib.remove_elements import ( + ElementsToRemove, + remove_specified_elements, +) from pcs.lib.cib.resource import ( guest_node, primitive, @@ -22,6 +24,7 @@ IdProvider, get_resources, ) +from pcs.lib.commands.cib import _stop_resources_wait # TODO lib.commands should never import each other. This is to be removed when # the 'resource create' commands are overhauled. @@ -35,7 +38,6 @@ ) from pcs.lib.communication.tools import run as run_com from pcs.lib.communication.tools import run_and_raise -from pcs.lib.corosync.config_facade import ConfigFacade as CorosyncConfigFacade from pcs.lib.env import ( LibraryEnvironment, WaitType, @@ -53,6 +55,11 @@ ) from pcs.lib.tools import generate_binary_key +if TYPE_CHECKING: + from pcs.lib.corosync.config_facade import ( + ConfigFacade as CorosyncConfigFacade, + ) + def _reports_skip_new_node(new_node_name, reason_type): assert reason_type in {"unreachable", "not_live_cib"} @@ -245,13 +252,14 @@ def _ensure_resource_running(env: LibraryEnvironment, resource_id): raise LibraryError() -def node_add_remote( +def node_add_remote( # noqa: PLR0912, PLR0913, PLR0915 env: LibraryEnvironment, node_name: str, node_addr: Optional[str], operations: Iterable[Mapping[str, str]], meta_attributes: Mapping[str, str], instance_attributes: Mapping[str, str], + *, skip_offline_nodes: bool = False, allow_incomplete_distribution: bool = False, allow_pacemaker_remote_service_fail: bool = False, @@ -354,27 +362,22 @@ def node_add_remote( ) ) ) - else: - # default node_addr to an address from known-hosts - if node_addr is None: - known_hosts = env.get_known_hosts([node_name]) - if known_hosts: - node_addr = known_hosts[0].dest.addr - node_addr_source = ( - reports.const.DEFAULT_ADDRESS_SOURCE_KNOWN_HOSTS - ) - else: - node_addr = node_name - node_addr_source = ( - reports.const.DEFAULT_ADDRESS_SOURCE_HOST_NAME - ) - report_processor.report( - ReportItem.info( - reports.messages.UsingDefaultAddressForHost( - node_name, node_addr, node_addr_source - ) + # default node_addr to an address from known-hosts + elif node_addr is None: + known_hosts = env.get_known_hosts([node_name]) + if known_hosts: + node_addr = known_hosts[0].dest.addr + node_addr_source = reports.const.DEFAULT_ADDRESS_SOURCE_KNOWN_HOSTS + else: + node_addr = node_name + node_addr_source = reports.const.DEFAULT_ADDRESS_SOURCE_HOST_NAME + report_processor.report( + ReportItem.info( + reports.messages.UsingDefaultAddressForHost( + node_name, node_addr, node_addr_source ) ) + ) # validate inputs report_list = remote_node.validate_create( @@ -452,7 +455,7 @@ def node_add_remote( _ensure_resource_running(env, remote_resource_element.attrib["id"]) -def node_add_guest( +def node_add_guest( # noqa: PLR0912, PLR0915 env: LibraryEnvironment, node_name, resource_id, @@ -537,26 +540,23 @@ def node_add_guest( ) ) ) - else: - # default remote-addr to an address from known-hosts - if "remote-addr" not in options or options["remote-addr"] is None: - known_hosts = env.get_known_hosts([node_name]) - if known_hosts: - new_addr = known_hosts[0].dest.addr - new_addr_source = ( - reports.const.DEFAULT_ADDRESS_SOURCE_KNOWN_HOSTS - ) - else: - new_addr = node_name - new_addr_source = reports.const.DEFAULT_ADDRESS_SOURCE_HOST_NAME - options["remote-addr"] = new_addr - report_processor.report( - ReportItem.info( - reports.messages.UsingDefaultAddressForHost( - node_name, new_addr, new_addr_source - ) + # default remote-addr to an address from known-hosts + elif "remote-addr" not in options or options["remote-addr"] is None: + known_hosts = env.get_known_hosts([node_name]) + if known_hosts: + new_addr = known_hosts[0].dest.addr + new_addr_source = reports.const.DEFAULT_ADDRESS_SOURCE_KNOWN_HOSTS + else: + new_addr = node_name + new_addr_source = reports.const.DEFAULT_ADDRESS_SOURCE_HOST_NAME + options["remote-addr"] = new_addr + report_processor.report( + ReportItem.info( + reports.messages.UsingDefaultAddressForHost( + node_name, new_addr, new_addr_source ) ) + ) # validate inputs report_list = guest_node.validate_set_as_guest( @@ -605,13 +605,13 @@ def node_add_guest( def _find_resources_to_remove( - cib, + cib: _Element, report_processor: ReportProcessor, - node_type, - node_identifier, - allow_remove_multiple_nodes, - find_resources, -): + node_type: str, + node_identifier: str, + allow_remove_multiple_nodes: bool, + find_resources: Callable[[_Element, str], list[_Element]], +) -> list[_Element]: resource_element_list = find_resources(get_resources(cib), node_identifier) if not resource_element_list: @@ -622,7 +622,7 @@ def _find_resources_to_remove( ) if len(resource_element_list) > 1: - if report_processor.report( + report_processor.report( ReportItem( severity=reports.item.get_severity( reports.codes.FORCE, @@ -631,13 +631,14 @@ def _find_resources_to_remove( message=reports.messages.MultipleResultsFound( "resource", [ - resource.attrib["id"] + str(resource.attrib["id"]) for resource in resource_element_list ], node_identifier, ), ) - ).has_errors: + ) + if report_processor.has_errors: raise LibraryError() return resource_element_list @@ -699,35 +700,28 @@ def _report_skip_live_parts_in_remove(node_names_list): def node_remove_remote( - env, - node_identifier, - remove_resource, - skip_offline_nodes=False, - allow_remove_multiple_nodes=False, - allow_pacemaker_remote_service_fail=False, + env: LibraryEnvironment, + node_identifier: str, + force_flags: reports.types.ForceFlags = (), ): """ remove a resource representing remote node and destroy remote node - LibraryEnvironment env provides all for communication with externals - string node_identifier -- node name or hostname - callable remove_resource -- function for remove resource - bool skip_offline_nodes -- a flag for ignoring when some nodes are offline - bool allow_remove_multiple_nodes -- is a flag for allowing - remove unexpected multiple occurrence of remote node for node_identifier - bool allow_pacemaker_remote_service_fail -- is a flag for allowing - successfully finish this command even if stoping/disabling - pacemaker_remote not succeeded + env -- provides all for communication with externals + node_identifier -- node name or hostname + force_flags -- list of flags codes """ - cib = env.get_cib() + report_processor = env.report_processor + force = reports.codes.FORCE in force_flags + resource_element_list = _find_resources_to_remove( cib, - env.report_processor, + report_processor, "remote", node_identifier, - allow_remove_multiple_nodes, - remote_node.find_node_resources, + allow_remove_multiple_nodes=force, + find_resources=remote_node.find_node_resources, ) node_names_list = sorted( @@ -737,25 +731,49 @@ def node_remove_remote( } ) + resource_ids = [str(el.attrib["id"]) for el in resource_element_list] + elements_to_remove = ElementsToRemove(cib, resource_ids) + + # the user could have provided hostname, so we want to show them which + # resources are going to be removed + report_processor.report( + reports.ReportItem.info( + reports.messages.CibRemoveResources(resource_ids) + ) + ) + + report_processor.report_list( + elements_to_remove.dependant_elements.to_reports() + ) + report_processor.report_list( + elements_to_remove.element_references.to_reports() + ) + + # we use private function from lib.commands.cib to reduce code repetition + cib = _stop_resources_wait( + env, cib, elements_to_remove.resources_to_remove, force_flags + ) + if not env.is_cib_live: - env.report_processor.report_list( + report_processor.report_list( _report_skip_live_parts_in_remove(node_names_list) ) else: _destroy_pcmk_remote_env( env, node_names_list, - skip_offline_nodes, - allow_pacemaker_remote_service_fail, + skip_offline_nodes=reports.codes.SKIP_OFFLINE_NODES in force_flags, + allow_fails=force, ) - # remove node from pcmk caches is currently integrated in remove_resource - # function - for resource_element in resource_element_list: - remove_resource( - resource_element.attrib["id"], - is_remove_remote_context=True, - ) + remove_specified_elements(cib, elements_to_remove) + + env.push_cib() + + # remove node from pcmk caches + if env.is_cib_live: + for node_name in node_names_list: + remove_node(env.cmd_runner(), node_name) def node_remove_guest( diff --git a/pcs/lib/commands/resource.py b/pcs/lib/commands/resource.py index 5e749bad3..9bcdb6f01 100644 --- a/pcs/lib/commands/resource.py +++ b/pcs/lib/commands/resource.py @@ -26,7 +26,7 @@ ) from pcs.common.interface import dto from pcs.common.pacemaker.resource.list import CibResourcesDto -from pcs.common.reports import ReportItemList +from pcs.common.reports import ReportItemList, ReportProcessor from pcs.common.reports.item import ReportItem from pcs.common.tools import ( Version, @@ -36,6 +36,13 @@ from pcs.lib.cib import const as cib_const from pcs.lib.cib import resource from pcs.lib.cib import status as cib_status +from pcs.lib.cib.nvpair_multi import ( + NVSET_META, + find_nvsets, + nvset_append_new, + nvset_to_dict_except_without_values, + nvset_update, +) from pcs.lib.cib.tag import expand_tag from pcs.lib.cib.tools import ( ElementNotFound, @@ -43,6 +50,7 @@ find_element_by_tag_and_id, get_element_by_id, get_elements_by_ids, + get_pacemaker_version_by_which_cib_was_validated, get_resources, get_status, ) @@ -64,8 +72,10 @@ get_cluster_status_dom, has_resource_unmove_unban_expired_support, push_cib_diff_xml, + remove_node, resource_ban, resource_move, + resource_restart, resource_unmove_unban, simulate_cib, ) @@ -91,6 +101,8 @@ resource_agent_error_to_report_item, split_resource_agent_name, ) +from pcs.lib.resource_agent.const import OCF_1_1 +from pcs.lib.sbd_stonith import ensure_some_stonith_remains from pcs.lib.tools import get_tmp_cib from pcs.lib.validate import ValueTimeInterval from pcs.lib.xml_tools import ( @@ -101,7 +113,7 @@ @contextmanager def resource_environment( - env, + env: LibraryEnvironment, wait: WaitType = False, wait_for_resource_ids=None, resource_state_reporter=info_resource_state, @@ -156,60 +168,66 @@ def inner(state, resource_id): return inner -def _get_agent_facade( - report_processor: reports.ReportProcessor, - runner: CommandRunner, - factory: ResourceAgentFacadeFactory, - name: str, - allow_absent_agent: bool, -) -> ResourceAgentFacade: +def _get_resource_agent_name( + runner: CommandRunner, report_processor: reports.ReportProcessor, name: str +) -> ResourceAgentName: try: - split_name = ( + agent_name = ( split_resource_agent_name(name) if ":" in name else find_one_resource_agent_by_type(runner, report_processor, name) ) - if split_name.is_stonith: - report_processor.report( - reports.ReportItem.deprecation( - reports.messages.ResourceStonithCommandsMismatch( - "fence agent", reports.const.PCS_COMMAND_STONITH_CREATE - ) - ) - ) - if split_name.standard in ("nagios", "upstart"): - # TODO deprecated in pacemaker 2, to be removed in pacemaker 3 - # added to pcs after 0.11.7 - report_processor.report( - reports.ReportItem.deprecation( - reports.messages.DeprecatedOptionValue( - "standard", split_name.standard - ) - ) + except ResourceAgentError as e: + report_processor.report( + resource_agent_error_to_report_item( + e, reports.ReportItemSeverity.error() ) + ) + raise LibraryError() from e - return factory.facade_from_parsed_name(split_name) - except (UnableToGetAgentMetadata, UnsupportedOcfVersion) as e: - if allow_absent_agent: - report_processor.report( - resource_agent_error_to_report_item( - e, reports.ReportItemSeverity.warning() + if agent_name.is_stonith: + report_processor.report( + reports.ReportItem.deprecation( + reports.messages.ResourceStonithCommandsMismatch( + "fence agent", reports.const.PCS_COMMAND_STONITH_CREATE ) ) - return factory.void_facade_from_parsed_name(split_name) + ) + + if agent_name.standard in ("nagios", "upstart"): + # TODO deprecated in pacemaker 2, to be removed in pacemaker 3 + # added to pcs after 0.11.7 report_processor.report( - resource_agent_error_to_report_item( - e, reports.ReportItemSeverity.error(reports.codes.FORCE) + reports.ReportItem.deprecation( + reports.messages.DeprecatedOptionValue( + "standard", agent_name.standard + ) ) ) - raise LibraryError() from e - except ResourceAgentError as e: + + return agent_name + + +def _get_resource_agent_facade( + report_processor: reports.ReportProcessor, + factory: ResourceAgentFacadeFactory, + agent_name: ResourceAgentName, + force_flags: reports.types.ForceFlags, +) -> ResourceAgentFacade: + try: + return factory.facade_from_parsed_name(agent_name) + except (UnableToGetAgentMetadata, UnsupportedOcfVersion) as e: report_processor.report( resource_agent_error_to_report_item( - e, reports.ReportItemSeverity.error() + e, + reports.get_severity_from_flags( + reports.codes.FORCE, force_flags + ), ) ) - raise LibraryError() from e + if report_processor.has_errors: + raise LibraryError() from e + return factory.void_facade_from_parsed_name(agent_name) def _validate_remote_connection( @@ -354,18 +372,110 @@ def _check_special_cases( raise LibraryError() +def _validate_clone_meta_attributes( + report_processor: ReportProcessor, + agent_facade_factory: ResourceAgentFacadeFactory, + resource_el: _Element, + meta_attrs: Mapping[str, str], + force_flags: reports.types.ForceFlags, +) -> None: + clone_child_el = resource.clone.get_inner_resource(resource_el) + if clone_child_el is None: + return + + group_id = None + if resource.group.is_group(clone_child_el): + group_id = str(clone_child_el.attrib["id"]) + + inner_primitives = resource.clone.get_inner_primitives(resource_el) + + facade_cache: dict[ResourceAgentName, ResourceAgentFacade] = {} + for primitive_el in inner_primitives: + agent_name = resource.primitive.resource_agent_name_from_primitive( + primitive_el + ) + if agent_name.is_ocf: + if agent_name in facade_cache: + agent_facade = facade_cache[agent_name] + else: + agent_facade = _get_resource_agent_facade( + report_processor, + agent_facade_factory, + agent_name, + force_flags, + ) + facade_cache[agent_name] = agent_facade + + if ( + agent_facade.metadata.ocf_version == OCF_1_1 + and is_true(meta_attrs.get(resource.clone.META_PROMOTABLE, "0")) + and not agent_facade.metadata.provides_promotability + ): + report_processor.report( + reports.ReportItem( + reports.get_severity_from_flags( + reports.codes.FORCE, + force_flags, + ), + reports.messages.ResourceCloneIncompatibleMetaAttributes( + resource.clone.META_PROMOTABLE, + agent_name.to_dto(), + resource_id=primitive_el.get("id"), + group_id=group_id, + ), + ) + ) + else: + report_processor.report_list( + [ + reports.ReportItem.error( + reports.messages.ResourceCloneIncompatibleMetaAttributes( + incompatible_attr, + agent_name.to_dto(), + resource_id=primitive_el.get("id"), + group_id=group_id, + ) + ) + for incompatible_attr in [ + resource.clone.META_GLOBALLY_UNIQUE, + resource.clone.META_PROMOTABLE, + ] + if is_true(meta_attrs.get(incompatible_attr, "0")) + ] + ) + + _find_bundle = partial( find_element_by_tag_and_id, cib_const.TAG_RESOURCE_BUNDLE ) -def create( +def _are_meta_disabled(meta_attributes: Mapping[str, str]) -> bool: + return meta_attributes.get("target-role", "Started").lower() == "stopped" + + +def _can_be_evaluated_as_positive_num(value: str) -> bool: + string_wo_leading_zeros = str(value).lstrip("0") + return bool(string_wo_leading_zeros) and ( + string_wo_leading_zeros[0] in list("123456789") + ) + + +def _is_clone_deactivated_by_meta(meta_attributes: Mapping[str, str]) -> bool: + return _are_meta_disabled(meta_attributes) or any( + not _can_be_evaluated_as_positive_num(meta_attributes.get(key, "1")) + for key in ["clone-max", "clone-node-max"] + ) + + +def create( # noqa: PLR0913 env: LibraryEnvironment, resource_id: str, resource_agent_name: str, operation_list: List[Mapping[str, str]], meta_attributes: Mapping[str, str], instance_attributes: Mapping[str, str], + *, allow_absent_agent: bool = False, allow_invalid_operation: bool = False, allow_invalid_instance_attributes: bool = False, @@ -413,20 +523,21 @@ def create( """ runner = env.cmd_runner() agent_factory = ResourceAgentFacadeFactory(runner, env.report_processor) - resource_agent = _get_agent_facade( + agent_name = _get_resource_agent_name( + runner, env.report_processor, resource_agent_name + ) + resource_agent = _get_resource_agent_facade( env.report_processor, - runner, agent_factory, - resource_agent_name, - allow_absent_agent, + agent_name, + [reports.codes.FORCE] if allow_absent_agent else [], ) with resource_environment( env, wait, [resource_id], _ensure_disabled_after_wait( - ensure_disabled - or resource.common.are_meta_disabled(meta_attributes) + ensure_disabled or _are_meta_disabled(meta_attributes) ), required_cib_version=get_required_cib_version_for_primitive( operation_list @@ -465,7 +576,7 @@ def create( resource.common.disable(primitive_element, id_provider) -def create_as_clone( +def create_as_clone( # noqa: PLR0913 env: LibraryEnvironment, resource_id: str, resource_agent_name: str, @@ -473,6 +584,7 @@ def create_as_clone( meta_attributes: Mapping[str, str], instance_attributes: Mapping[str, str], clone_meta_options: Mapping[str, str], + *, clone_id: Optional[str] = None, allow_absent_agent: bool = False, allow_invalid_operation: bool = False, @@ -519,12 +631,14 @@ def create_as_clone( """ runner = env.cmd_runner() agent_factory = ResourceAgentFacadeFactory(runner, env.report_processor) - resource_agent = _get_agent_facade( + agent_name = _get_resource_agent_name( + runner, env.report_processor, resource_agent_name + ) + resource_agent = _get_resource_agent_facade( env.report_processor, - runner, agent_factory, - resource_agent_name, - allow_absent_agent, + agent_name, + [reports.codes.FORCE] if allow_absent_agent else [], ) if resource_agent.metadata.name.standard != "ocf": for incompatible_attr in ("globally-unique", "promotable"): @@ -563,8 +677,8 @@ def create_as_clone( [resource_id], _ensure_disabled_after_wait( ensure_disabled - or resource.common.are_meta_disabled(meta_attributes) - or resource.common.is_clone_deactivated_by_meta(clone_meta_options) + or _are_meta_disabled(meta_attributes) + or _is_clone_deactivated_by_meta(clone_meta_options) ), required_cib_version=get_required_cib_version_for_primitive( operation_list @@ -614,7 +728,7 @@ def create_as_clone( resource.common.disable(clone_element, id_provider) -def create_in_group( +def create_in_group( # noqa: PLR0913 env: LibraryEnvironment, resource_id: str, resource_agent_name: str, @@ -622,6 +736,7 @@ def create_in_group( operation_list: List[Mapping[str, str]], meta_attributes: Mapping[str, str], instance_attributes: Mapping[str, str], + *, allow_absent_agent: bool = False, allow_invalid_operation: bool = False, allow_invalid_instance_attributes: bool = False, @@ -636,50 +751,51 @@ def create_in_group( # pylint: disable=too-many-arguments # pylint: disable=too-many-locals """ - Create resource in a cib and put it into defined group + Create a resource in a cib and put it into a defined group env -- provides all for communication with externals - resource_id -- is identifier of resource - resource_agent_name -- contains name for the identification of agent - group_id -- is identificator for group to put primitive resource inside + resource_id -- new primitive resource identifier + resource_agent_name -- primitive resource agent name + group_id -- name of a group to put the primitive resource inside operation_list -- contains attributes for each entered operation meta_attributes -- contains attributes for primitive/meta_attributes instance_attributes -- contains attributes for primitive/instance_attributes allow_absent_agent -- is a flag for allowing agent that is not installed in a system allow_invalid_operation -- is a flag for allowing to use operations that - are not listed in a resource agent metadata + are not listed in the resource agent metadata allow_invalid_instance_attributes -- is a flag for allowing to use - instance attributes that are not listed in a resource agent metadata + instance attributes that are not listed in the resource agent metadata or for allowing to not use the instance_attributes that are required in resource agent metadata - use_default_operations -- is a flag for stopping stopping of adding - default cib operations (specified in a resource agent) + use_default_operations -- is a flag for stopping of adding default cib + operations (specified in the resource agent) ensure_disabled -- is flag that keeps resource in target-role "Stopped" - adjacent_resource_id -- identify neighbor of a newly created resource - put_after_adjacent -- is flag to put a newly create resource befor/after - adjacent resource - wait -- is flag for controlling waiting for pacemaker idle mechanism + adjacent_resource_id -- identify neighbor of the newly created resource + put_after_adjacent -- flag to put the newly create resource before / after + the adjacent resource + wait -- flag for controlling waiting for pacemaker idle mechanism allow_not_suitable_command -- turn forceable errors into warnings enable_agent_self_validation -- if True, use agent self-validation feature to validate instance attributes """ runner = env.cmd_runner() agent_factory = ResourceAgentFacadeFactory(runner, env.report_processor) - resource_agent = _get_agent_facade( + agent_name = _get_resource_agent_name( + runner, env.report_processor, resource_agent_name + ) + resource_agent = _get_resource_agent_facade( env.report_processor, - runner, agent_factory, - resource_agent_name, - allow_absent_agent, + agent_name, + [reports.codes.FORCE] if allow_absent_agent else [], ) with resource_environment( env, wait, [resource_id], _ensure_disabled_after_wait( - ensure_disabled - or resource.common.are_meta_disabled(meta_attributes) + ensure_disabled or _are_meta_disabled(meta_attributes) ), required_cib_version=get_required_cib_version_for_primitive( operation_list @@ -760,7 +876,7 @@ def create_in_group( ) -def create_into_bundle( +def create_into_bundle( # noqa: PLR0913 env: LibraryEnvironment, resource_id: str, resource_agent_name: str, @@ -768,6 +884,7 @@ def create_into_bundle( meta_attributes: Mapping[str, str], instance_attributes: Mapping[str, str], bundle_id: str, + *, allow_absent_agent: bool = False, allow_invalid_operation: bool = False, allow_invalid_instance_attributes: bool = False, @@ -810,12 +927,14 @@ def create_into_bundle( """ runner = env.cmd_runner() agent_factory = ResourceAgentFacadeFactory(runner, env.report_processor) - resource_agent = _get_agent_facade( + agent_name = _get_resource_agent_name( + runner, env.report_processor, resource_agent_name + ) + resource_agent = _get_resource_agent_facade( env.report_processor, - runner, agent_factory, - resource_agent_name, - allow_absent_agent, + agent_name, + [reports.codes.FORCE] if allow_absent_agent else [], ) required_cib_version = get_required_cib_version_for_primitive( operation_list @@ -825,8 +944,7 @@ def create_into_bundle( wait, [resource_id], _ensure_disabled_after_wait( - ensure_disabled - or resource.common.are_meta_disabled(meta_attributes) + ensure_disabled or _are_meta_disabled(meta_attributes) ), required_cib_version=required_cib_version, ) as resources_section: @@ -878,10 +996,11 @@ def create_into_bundle( resource.bundle.add_resource(bundle_el, primitive_element) -def bundle_create( +def bundle_create( # noqa: PLR0913 env, bundle_id, container_type, + *, container_options=None, network_options=None, port_map=None, @@ -918,8 +1037,7 @@ def bundle_create( wait, [bundle_id], _ensure_disabled_after_wait( - ensure_disabled - or resource.common.are_meta_disabled(meta_attributes) + ensure_disabled or _are_meta_disabled(meta_attributes) ), required_cib_version=( Version(3, 2, 0) if container_type == "podman" else None @@ -957,9 +1075,10 @@ def bundle_create( resource.common.disable(bundle_element, id_provider) -def bundle_reset( +def bundle_reset( # noqa: PLR0913 env, bundle_id, + *, container_options=None, network_options=None, port_map=None, @@ -995,8 +1114,7 @@ def bundle_reset( wait, [bundle_id], _ensure_disabled_after_wait( - ensure_disabled - or resource.common.are_meta_disabled(meta_attributes) + ensure_disabled or _are_meta_disabled(meta_attributes) ), # The only requirement for CIB schema version currently is: # if container_type == "podman" then required_version = '3.2.0' @@ -1041,9 +1159,10 @@ def bundle_reset( resource.common.disable(bundle_element, id_provider) -def bundle_update( +def bundle_update( # noqa: PLR0913 env, bundle_id, + *, container_options=None, network_options=None, port_map_add=None, @@ -1115,27 +1234,6 @@ def bundle_update( ) -def _disable_validate_and_edit_cib( - env: LibraryEnvironment, - cib: _Element, - resource_or_tag_ids: StringCollection, -) -> List[_Element]: - resource_el_list, report_list = _find_resources_expand_tags( - cib, resource_or_tag_ids - ) - env.report_processor.report_list(report_list) - if env.report_processor.report_list( - _resource_list_enable_disable( - resource_el_list, - resource.common.disable, - IdProvider(cib), - env.get_cluster_state(), - ) - ).has_errors: - raise LibraryError() - return resource_el_list - - def _disable_get_element_ids( disabled_resource_el_list: Iterable[_Element], ) -> Tuple[Set[str], Set[str]]: @@ -1204,6 +1302,7 @@ def disable( env: LibraryEnvironment, resource_or_tag_ids: StringCollection, wait: WaitType = False, + force_flags: reports.types.ForceFlags = (), ): """ Disallow specified resources to be started by the cluster @@ -1214,7 +1313,50 @@ def disable( wait -- False: no wait, None: wait default timeout, int: wait timeout """ wait_timeout = env.ensure_wait_satisfiable(wait) - _disable_validate_and_edit_cib(env, env.get_cib(), resource_or_tag_ids) + cib = env.get_cib() + resource_el_list, report_list = _find_resources_expand_tags( + cib, resource_or_tag_ids + ) + env.report_processor.report_list(report_list) + + stonith_to_be_disabled = [] + for resource_el in resource_el_list: + stonith_to_be_disabled += [ + primitive_el + for primitive_el in resource.common.get_all_inner_resources( + resource_el + ) + | {resource_el} + if resource.stonith.is_stonith(primitive_el) + ] + + if stonith_to_be_disabled: + env.report_processor.report_list( + ensure_some_stonith_remains( + env, + get_resources(cib), + [str(res.attrib["id"]) for res in stonith_to_be_disabled], + sbd_being_disabled=False, + force_flags=force_flags, + ) + ) + + # Validation done, do the disabling. Do not mind errors that happened so + # far. The disabling may report errors on its own and we want the user to + # see those. In case of errors, we exit before pushing CIB, not making any + # change to cluster configuration. + env.report_processor.report_list( + _resource_list_enable_disable( + resource_el_list, + resource.common.disable, + IdProvider(cib), + env.get_cluster_state(), + ) + ) + + if env.report_processor.has_errors: + raise LibraryError() + _push_cib_wait( env, wait_timeout, @@ -1248,9 +1390,11 @@ def disable_safe( wait_timeout = env.ensure_wait_satisfiable(wait) cib = env.get_cib() - resource_el_list = _disable_validate_and_edit_cib( - env, cib, resource_or_tag_ids + resource_el_list, report_list = _find_resources_expand_tags( + cib, resource_or_tag_ids ) + env.report_processor.report_list(report_list) + if any( resource.stonith.is_stonith(resource_el) for resource_el in resource_el_list @@ -1262,6 +1406,22 @@ def disable_safe( ) ) ) + + # Validation done, do the disabling. Do not mind errors that happened so + # far. The disabling may report errors on its own and we want the user to + # see those. In case of errors, we exit before pushing CIB, not making any + # change to cluster configuration. + env.report_processor.report_list( + _resource_list_enable_disable( + resource_el_list, + resource.common.disable, + IdProvider(cib), + env.get_cluster_state(), + ) + ) + if env.report_processor.has_errors: + raise LibraryError() + disabled_resource_id_set, inner_resource_id_set = _disable_get_element_ids( resource_el_list ) @@ -1319,9 +1479,27 @@ def disable_simulate( ) cib = env.get_cib() - resource_el_list = _disable_validate_and_edit_cib( - env, cib, resource_or_tag_ids + resource_el_list, report_list = _find_resources_expand_tags( + cib, resource_or_tag_ids + ) + env.report_processor.report_list(report_list) + + # Validation done, do the disabling. Do not mind errors that happened so + # far. The disabling may report errors on its own and we want the user to + # see those. In case of errors, we exit before pushing CIB, not making any + # change to cluster configuration. + env.report_processor.report_list( + _resource_list_enable_disable( + resource_el_list, + resource.common.disable, + IdProvider(cib), + env.get_cluster_state(), + ) ) + + if env.report_processor.has_errors: + raise LibraryError() + disabled_resource_id_set, inner_resource_id_set = _disable_get_element_ids( resource_el_list ) @@ -1380,11 +1558,14 @@ def enable( def _resource_list_enable_disable( - resource_el_list, func, id_provider, cluster_state -): + resource_el_list: Iterable[_Element], + func: Callable[[_Element, IdProvider], None], + id_provider: IdProvider, + cluster_state, +) -> ReportItemList: report_list = [] for resource_el in resource_el_list: - res_id = resource_el.attrib["id"] + res_id = str(resource_el.attrib["id"]) try: if not is_resource_managed(cluster_state, res_id): report_list.append( @@ -1502,7 +1683,7 @@ def manage( env.push_cib() -def group_add( +def group_add( # noqa: PLR0912 env: LibraryEnvironment, group_id: str, resource_id_list: List[str], @@ -1600,10 +1781,10 @@ def group_add( and resource.group.is_group(old_parent) and str(old_parent.attrib["id"]) not in all_resources ): - all_resources[str(old_parent.attrib["id"])] = set( + all_resources[str(old_parent.attrib["id"])] = { str(res.attrib["id"]) for res in resource.common.get_inner_resources(old_parent) - ) + } affected_resources = set(resource_id_list) # Set comparison step to determine if groups will be emptied by move @@ -1633,9 +1814,8 @@ def group_add( isinstance(report.message, reports.messages.CibPushError) for report in e.args ): - if env.report_processor.report_list( - empty_group_report_list - ).has_errors: + env.report_processor.report_list(empty_group_report_list) + if env.report_processor.has_errors: raise LibraryError() from None except AttributeError: # For accessing message inside something that's not a report @@ -1732,7 +1912,7 @@ def other_resources_affected(self) -> bool: return self._other_resources_affected -def move_autoclean( +def move_autoclean( # noqa: PLR0912, PLR0915 env: LibraryEnvironment, resource_id: str, node: Optional[str] = None, @@ -2006,14 +2186,13 @@ def _ensure_resource_moved_and_not_moved_back( if strict: if clean_operations: raise ResourceMoveAutocleanSimulationFailure(True) - else: - if any( - rsc == resource_id - for rsc in simulate_tools.get_resources_from_operations( - clean_operations - ) - ): - raise ResourceMoveAutocleanSimulationFailure(False) + elif any( + rsc == resource_id + for rsc in simulate_tools.get_resources_from_operations( + clean_operations + ) + ): + raise ResourceMoveAutocleanSimulationFailure(False) def ban(env, resource_id, node=None, master=False, lifetime=None, wait=False): @@ -2033,7 +2212,7 @@ def ban(env, resource_id, node=None, master=False, lifetime=None, wait=False): def _resource_running_on_nodes( - resource_state: Dict[str, List[str]] + resource_state: Dict[str, List[str]], ) -> FrozenSet[str]: if resource_state: return frozenset( @@ -2381,30 +2560,26 @@ def unmove_unban( raise LibraryError() -def get_resource_relations_tree( - env: LibraryEnvironment, +def _find_resource_elem( + cib: _Element, resource_id: str, -) -> Mapping[str, Any]: +) -> _Element: """ - Return a dict representing tree-like structure of resources and their - relations. + Find a resource element in CIB and handle errors. - env -- library environment - resource_id -- id of a resource which should be the root of the relation - tree + cib -- CIB + resource_id -- name of the resource """ - cib = env.get_cib() - try: resource_el = get_element_by_id(cib, resource_id) - except ElementNotFound as e: + except ElementNotFound as exc: raise LibraryError( ReportItem.error( reports.messages.IdNotFound( resource_id, expected_types=["resource"] ) ) - ) from e + ) from exc if not resource.common.is_resource(resource_el): raise LibraryError( ReportItem.error( @@ -2415,6 +2590,23 @@ def get_resource_relations_tree( ) ) ) + return resource_el + + +def get_resource_relations_tree( + env: LibraryEnvironment, + resource_id: str, +) -> Mapping[str, Any]: + """ + Return a dict representing tree-like structure of resources and their + relations. + + env -- library environment + resource_id -- id of a resource which should be the root of the relation + tree + """ + cib = env.get_cib() + resource_el = _find_resource_elem(cib, resource_id) if resource.stonith.is_stonith(resource_el): env.report_processor.report( reports.ReportItem.deprecation( @@ -2465,7 +2657,7 @@ def _find_resources_expand_tags( def get_required_cib_version_for_primitive( - op_list: Iterable[Mapping[str, str]] + op_list: Iterable[Mapping[str, str]], ) -> Optional[Version]: for op in op_list: if op.get("on-fail", "") == "demote": @@ -2542,3 +2734,186 @@ def get_configured_resources(env: LibraryEnvironment) -> CibResourcesDto: ], bundles=bundles, ) + + +def restart( + env: LibraryEnvironment, + resource_id: str, + node: Optional[str] = None, + timeout: Optional[str] = None, +) -> None: + """ + Restart a resource + + resource_id -- id of the resource to be restarted + node -- name of the node to limit the restart to + timeout -- abort if the command doesn't finish in this time (integer + unit) + """ + cib = env.get_cib() + + # To be able to restart bundle instances, which are not to be found in CIB, + # do not fail if specified ID is not found in CIB. Pacemaker provides + # reasonable messages when the ID to be restarted is not a resource or + # doesn't exist. We only search for the resource in order to provide hints + # when the user attempts to restart bundle's or clone's inner resources. + resource_found = False + try: + resource_el = get_element_by_id(cib, resource_id) + resource_found = True + except ElementNotFound: + pass + + if resource_found: + if not resource.common.is_resource(resource_el): + env.report_processor.report( + ReportItem.error( + reports.messages.IdBelongsToUnexpectedType( + resource_id, + expected_types=["resource"], + current_type=resource_el.tag, + ) + ) + ) + raise LibraryError() + + parent_resource_el = resource.clone.get_parent_any_clone(resource_el) + if parent_resource_el is None: + parent_resource_el = resource.bundle.get_parent_bundle(resource_el) + if parent_resource_el is not None: + env.report_processor.report( + reports.ReportItem.warning( + reports.messages.ResourceRestartUsingParentRersource( + str(resource_el.attrib["id"]), + str(parent_resource_el.attrib["id"]), + ) + ) + ) + resource_el = parent_resource_el + + if node and not ( + resource.clone.is_any_clone(resource_el) + or resource.bundle.is_bundle(resource_el) + ): + env.report_processor.report( + reports.ReportItem.error( + reports.messages.ResourceRestartNodeIsForMultiinstanceOnly( + str(resource_el.attrib["id"]), + resource_el.tag, + node, + ) + ) + ) + + if timeout is not None: + env.report_processor.report_list( + ValueTimeInterval("timeout").validate({"timeout": timeout}) + ) + + if env.report_processor.has_errors: + raise LibraryError() + + resource_restart( + env.cmd_runner(), + str(resource_el.attrib["id"]) if resource_found else resource_id, + node=node, + timeout=timeout, + ) + + +def update_meta( + env: LibraryEnvironment, + resource_id: str, + meta_attrs: Mapping[str, str], + force_flags: reports.types.ForceFlags, +) -> None: + """ + Update meta attributes of all resource types without stonith check + + env -- library environment + resource_id -- id of resource to update + meta_attrs -- meta attributes to update with desired values + force_flags -- force flags + """ + cib = env.get_cib() + resource_el = _find_resource_elem(cib, resource_id) + id_provider = IdProvider(cib) + cib_validate_with = get_pacemaker_version_by_which_cib_was_validated(cib) + if resource.clone.is_master(resource_el): + resource.clone.convert_master_to_promotable( + id_provider, cib_validate_with, resource_el + ) + + meta_attrs_nvset_list = find_nvsets(resource_el, NVSET_META) + meta_attrs_nvset = ( + meta_attrs_nvset_list[0] if meta_attrs_nvset_list else None + ) + + ( + existing_nodes_names, + existing_nodes_addrs, + report_list, + ) = get_existing_nodes_names_addrs( + env.get_corosync_conf() if env.is_cib_live else None, + cib=cib, + ) + env.report_processor.report_list(report_list) + + existing_meta_attrs = ( + nvset_to_dict_except_without_values(meta_attrs_nvset) + if meta_attrs_nvset is not None + else {} + ) + if not resource.stonith.is_stonith(resource_el): + env.report_processor.report_list( + resource.guest_node.validate_updating_guest_attributes( + cib, + existing_nodes_names, + existing_nodes_addrs, + meta_attrs, + existing_meta_attrs, + force_flags, + ) + ) + + cmd_runner = env.cmd_runner() + + if resource.clone.is_any_clone(resource_el): + _validate_clone_meta_attributes( + env.report_processor, + ResourceAgentFacadeFactory(cmd_runner, env.report_processor), + resource_el, + meta_attrs, + force_flags, + ) + + if env.report_processor.has_errors: + raise LibraryError() + + # Do not add element if user didn't provide any value + if meta_attrs_nvset is None and any(meta_attrs.values()): + nvset_append_new( + resource_el, + id_provider, + cib_validate_with, + NVSET_META, + nvpair_dict=meta_attrs, + nvset_options={}, + ) + elif meta_attrs_nvset is not None: + nvset_update(meta_attrs_nvset, id_provider, meta_attrs) + + env.push_cib() + + # If remote node was removed or its name changed, it needs to be removed + # from pacemaker + if ( + reports.codes.FORCE in force_flags + and resource.guest_node.OPTION_REMOTE_NODE in meta_attrs + and resource.guest_node.OPTION_REMOTE_NODE in existing_meta_attrs + and existing_meta_attrs[resource.guest_node.OPTION_REMOTE_NODE] + != meta_attrs[resource.guest_node.OPTION_REMOTE_NODE] + ): + remove_node( + cmd_runner, + existing_meta_attrs[resource.guest_node.OPTION_REMOTE_NODE], + ) diff --git a/pcs/lib/commands/sbd.py b/pcs/lib/commands/sbd.py index b50d0bf2c..b70d1ddd7 100644 --- a/pcs/lib/commands/sbd.py +++ b/pcs/lib/commands/sbd.py @@ -1,10 +1,15 @@ +from typing import Any, Iterable, Mapping, Optional, TypeVar + from pcs import settings from pcs.common import reports -from pcs.common.reports.item import ReportItem +from pcs.common.node_communicator import RequestTarget +from pcs.common.types import StringSequence +from pcs.common.validate import is_integer from pcs.lib import ( sbd, validate, ) +from pcs.lib.cib.tools import get_resources from pcs.lib.communication.nodes import GetOnlineTargets from pcs.lib.communication.sbd import ( CheckSbd, @@ -18,17 +23,21 @@ ) from pcs.lib.communication.tools import run as run_com from pcs.lib.communication.tools import run_and_raise +from pcs.lib.env import LibraryEnvironment from pcs.lib.errors import LibraryError from pcs.lib.node import get_existing_nodes_names +from pcs.lib.sbd_stonith import ensure_some_stonith_remains from pcs.lib.tools import environment_file_to_dict -UNSUPPORTED_SBD_OPTION_LIST = [ +T = TypeVar("T") + +_UNSUPPORTED_SBD_OPTION_LIST = [ "SBD_WATCHDOG_DEV", "SBD_OPTS", "SBD_PACEMAKER", "SBD_DEVICE", ] -ALLOWED_SBD_OPTION_LIST = [ +_ALLOWED_SBD_OPTION_LIST = [ "SBD_DELAY_START", "SBD_STARTMODE", "SBD_WATCHDOG_TIMEOUT", @@ -38,13 +47,14 @@ {"flush", "noflush"}, {"reboot", "off", "crashdump"}, ) +_STARTMODE_ALLOWED_VALUES = ["always", "clean"] -def __tuple(set1, set2): +def __tuple(set1: set[str], set2: set[str]) -> set[str]: return {f"{v1},{v2}" for v1 in set1 for v2 in set2} -TIMEOUT_ACTION_ALLOWED_VALUE_LIST = sorted( +_TIMEOUT_ACTION_ALLOWED_VALUE_LIST = sorted( _TIMEOUT_ACTION_ALLOWED_VALUES[0] | _TIMEOUT_ACTION_ALLOWED_VALUES[1] | __tuple( @@ -56,28 +66,52 @@ def __tuple(set1, set2): ) +class _ValueSbdDelayStart(validate.ValuePredicateBase): + def _is_valid(self, value: validate.TypeOptionValue) -> bool: + # 1 means yes, so we don't allow it to prevent confusion + return value in ["yes", "no"] or is_integer(value, 2) + + def _get_allowed_values(self) -> Any: + return "'yes', 'no' or an integer greater than 1" + + def _validate_sbd_options( - sbd_config, allow_unknown_opts=False, allow_invalid_option_values=False -): + sbd_config: Mapping[str, str], + allow_unknown_opts: bool = False, + allow_invalid_option_values: bool = False, +) -> reports.ReportItemList: """ Validate user SBD configuration. Options 'SBD_WATCHDOG_DEV' and 'SBD_OPTS' - are restricted. Returns list of ReportItem + are restricted sbd_config -- dictionary in format: : allow_unknown_opts -- if True, accept also unknown options. """ validators = [ validate.NamesIn( - ALLOWED_SBD_OPTION_LIST, - banned_name_list=UNSUPPORTED_SBD_OPTION_LIST, + _ALLOWED_SBD_OPTION_LIST, + banned_name_list=_UNSUPPORTED_SBD_OPTION_LIST, severity=reports.item.get_severity( reports.codes.FORCE, allow_unknown_opts ), ), + _ValueSbdDelayStart( + "SBD_DELAY_START", + severity=reports.item.get_severity( + reports.codes.FORCE, allow_invalid_option_values + ), + ), + validate.ValueIn( + "SBD_STARTMODE", + _STARTMODE_ALLOWED_VALUES, + severity=reports.item.get_severity( + reports.codes.FORCE, allow_invalid_option_values + ), + ), validate.ValueNonnegativeInteger("SBD_WATCHDOG_TIMEOUT"), validate.ValueIn( "SBD_TIMEOUT_ACTION", - TIMEOUT_ACTION_ALLOWED_VALUE_LIST, + _TIMEOUT_ACTION_ALLOWED_VALUE_LIST, severity=reports.item.get_severity( reports.codes.FORCE, allow_invalid_option_values ), @@ -86,28 +120,33 @@ def _validate_sbd_options( return validate.ValidatorAll(validators).validate(sbd_config) -def _validate_watchdog_dict(watchdog_dict): +def _validate_watchdog_dict( + watchdog_dict: Mapping[str, str], +) -> reports.ReportItemList: """ Validates if all watchdogs are not empty strings. - Returns list of ReportItem. watchdog_dict -- dictionary with node names as keys and value as watchdog """ return [ - ReportItem.error(reports.messages.WatchdogInvalid(watchdog)) + reports.ReportItem.error(reports.messages.WatchdogInvalid(watchdog)) for watchdog in watchdog_dict.values() if not watchdog ] -def _get_full_target_dict(target_list, node_value_dict, default_value): +def _get_full_target_dict( + target_list: Iterable[RequestTarget], + node_value_dict: Mapping[str, T], + default_value: T, +) -> dict[str, T]: """ Returns dictionary where keys are labels of all nodes in cluster and value is obtained from node_value_dict for node name, or default value if node is not specified in node_value_dict. - list node_list -- list of cluster nodes (RequestTarget object) - node_value_dict -- dictionary, keys: node names, values: some velue + node_list -- cluster nodes + node_value_dict -- dictionary, keys: node names, values: some value default_value -- some default value """ return { @@ -116,24 +155,24 @@ def _get_full_target_dict(target_list, node_value_dict, default_value): } -def enable_sbd( - lib_env, - default_watchdog, - watchdog_dict, - sbd_options, - default_device_list=None, - node_device_dict=None, - allow_unknown_opts=False, - ignore_offline_nodes=False, - no_watchdog_validation=False, - allow_invalid_option_values=False, -): +def enable_sbd( # noqa: PLR0913 + lib_env: LibraryEnvironment, + default_watchdog: Optional[str], + watchdog_dict: Mapping[str, str], + sbd_options: Mapping[str, str], + default_device_list: Optional[StringSequence] = None, + node_device_dict: Optional[Mapping[str, StringSequence]] = None, + *, + allow_unknown_opts: bool = False, + ignore_offline_nodes: bool = False, + no_watchdog_validation: bool = False, + allow_invalid_option_values: bool = False, +) -> None: # pylint: disable=too-many-arguments # pylint: disable=too-many-locals """ Enable SBD on all nodes in cluster. - lib_env -- LibraryEnvironment default_watchdog -- watchdog for nodes which are not specified in watchdog_dict. Uses default value from settings if None. watchdog_dict -- dictionary with node names as keys and watchdog path @@ -165,7 +204,9 @@ def enable_sbd( node_list, get_nodes_report_list = get_existing_nodes_names(corosync_conf) if not node_list: get_nodes_report_list.append( - ReportItem.error(reports.messages.CorosyncConfigNoNodesDefined()) + reports.ReportItem.error( + reports.messages.CorosyncConfigNoNodesDefined() + ) ) target_list = lib_env.get_node_target_factory().get_target_list( node_list, @@ -182,7 +223,7 @@ def enable_sbd( if lib_env.report_processor.report_list( get_nodes_report_list + [ - ReportItem.error(reports.messages.NodeNotFound(node)) + reports.ReportItem.error(reports.messages.NodeNotFound(node)) for node in ( set(list(watchdog_dict.keys()) + list(node_device_dict.keys())) - set(node_list) @@ -200,21 +241,23 @@ def enable_sbd( ).has_errors: raise LibraryError() - com_cmd = GetOnlineTargets( + com_cmd_1 = GetOnlineTargets( lib_env.report_processor, ignore_offline_targets=ignore_offline_nodes, ) - com_cmd.set_targets(target_list) - online_targets = run_and_raise(lib_env.get_node_communicator(), com_cmd) + com_cmd_1.set_targets(target_list) + online_targets = run_and_raise(lib_env.get_node_communicator(), com_cmd_1) # check if SBD can be enabled if no_watchdog_validation: lib_env.report_processor.report( - ReportItem.warning(reports.messages.SbdWatchdogValidationInactive()) + reports.ReportItem.warning( + reports.messages.SbdWatchdogValidationInactive() + ) ) - com_cmd = CheckSbd(lib_env.report_processor) + com_cmd_2 = CheckSbd(lib_env.report_processor) for target in online_targets: - com_cmd.add_request( + com_cmd_2.add_request( target, ( # Do not send watchdog if validation is turned off. Listing of @@ -226,13 +269,13 @@ def enable_sbd( ), full_device_dict[target.label] if using_devices else [], ) - run_and_raise(lib_env.get_node_communicator(), com_cmd) + run_and_raise(lib_env.get_node_communicator(), com_cmd_2) # enable ATB if needed if not using_devices: if sbd.atb_has_to_be_enabled_pre_enable_check(corosync_conf): lib_env.report_processor.report( - ReportItem.warning( + reports.ReportItem.warning( reports.messages.CorosyncQuorumAtbWillBeEnabledDueToSbd() ) ) @@ -242,9 +285,9 @@ def enable_sbd( # distribute SBD configuration config = sbd.get_default_sbd_config() config.update(sbd_options) - com_cmd = SetSbdConfig(lib_env.report_processor) + com_cmd_3 = SetSbdConfig(lib_env.report_processor) for target in online_targets: - com_cmd.add_request( + com_cmd_3.add_request( target, sbd.create_sbd_config( config, @@ -253,70 +296,93 @@ def enable_sbd( full_device_dict[target.label], ), ) - run_and_raise(lib_env.get_node_communicator(), com_cmd) + run_and_raise(lib_env.get_node_communicator(), com_cmd_3) # remove cluster prop 'stonith_watchdog_timeout' - com_cmd = RemoveStonithWatchdogTimeout(lib_env.report_processor) - com_cmd.set_targets(online_targets) - run_and_raise(lib_env.get_node_communicator(), com_cmd) + com_cmd_4 = RemoveStonithWatchdogTimeout(lib_env.report_processor) + com_cmd_4.set_targets(online_targets) + run_and_raise(lib_env.get_node_communicator(), com_cmd_4) # enable SBD service an all nodes - com_cmd = EnableSbdService(lib_env.report_processor) - com_cmd.set_targets(online_targets) - run_and_raise(lib_env.get_node_communicator(), com_cmd) + com_cmd_5 = EnableSbdService(lib_env.report_processor) + com_cmd_5.set_targets(online_targets) + run_and_raise(lib_env.get_node_communicator(), com_cmd_5) lib_env.report_processor.report( - ReportItem.warning( + reports.ReportItem.warning( reports.messages.ClusterRestartRequiredToApplyChanges() ) ) -def disable_sbd(lib_env, ignore_offline_nodes=False): +def disable_sbd( + lib_env: LibraryEnvironment, + ignore_offline_nodes: bool = False, + force_flags: reports.types.ForceFlags = (), +) -> None: """ Disable SBD on all nodes in cluster. - lib_env -- LibraryEnvironment ignore_offline_nodes -- if True, omit offline nodes + force_flags -- list of flags codes """ - node_list, get_nodes_report_list = get_existing_nodes_names( + + skip_offline_nodes = ( + ignore_offline_nodes or reports.codes.SKIP_OFFLINE_NODES in force_flags + ) + + node_list, report_list = get_existing_nodes_names( lib_env.get_corosync_conf() ) if not node_list: - get_nodes_report_list.append( - ReportItem.error(reports.messages.CorosyncConfigNoNodesDefined()) + report_list.append( + reports.ReportItem.error( + reports.messages.CorosyncConfigNoNodesDefined() + ) ) - if lib_env.report_processor.report_list(get_nodes_report_list).has_errors: + report_list.extend( + ensure_some_stonith_remains( + lib_env, + get_resources(lib_env.get_cib()), + stonith_resources_to_ignore=[], + sbd_being_disabled=True, + force_flags=force_flags, + ) + ) + + if lib_env.report_processor.report_list(report_list).has_errors: raise LibraryError() - com_cmd = GetOnlineTargets( + com_cmd_1 = GetOnlineTargets( lib_env.report_processor, - ignore_offline_targets=ignore_offline_nodes, + ignore_offline_targets=skip_offline_nodes, ) - com_cmd.set_targets( + com_cmd_1.set_targets( lib_env.get_node_target_factory().get_target_list( node_list, - skip_non_existing=ignore_offline_nodes, + skip_non_existing=skip_offline_nodes, ) ) - online_nodes = run_and_raise(lib_env.get_node_communicator(), com_cmd) + online_nodes = run_and_raise(lib_env.get_node_communicator(), com_cmd_1) - com_cmd = SetStonithWatchdogTimeoutToZero(lib_env.report_processor) - com_cmd.set_targets(online_nodes) - run_and_raise(lib_env.get_node_communicator(), com_cmd) + com_cmd_2 = SetStonithWatchdogTimeoutToZero(lib_env.report_processor) + com_cmd_2.set_targets(online_nodes) + run_and_raise(lib_env.get_node_communicator(), com_cmd_2) - com_cmd = DisableSbdService(lib_env.report_processor) - com_cmd.set_targets(online_nodes) - run_and_raise(lib_env.get_node_communicator(), com_cmd) + com_cmd_3 = DisableSbdService(lib_env.report_processor) + com_cmd_3.set_targets(online_nodes) + run_and_raise(lib_env.get_node_communicator(), com_cmd_3) lib_env.report_processor.report( - ReportItem.warning( + reports.ReportItem.warning( reports.messages.ClusterRestartRequiredToApplyChanges() ) ) -def get_cluster_sbd_status(lib_env): +def get_cluster_sbd_status( + lib_env: LibraryEnvironment, +) -> dict[str, dict[str, bool]]: """ Returns status of SBD service in cluster in dictionary with format: { @@ -327,15 +393,15 @@ def get_cluster_sbd_status(lib_env): }, ... } - - lib_env -- LibraryEnvironment """ node_list, get_nodes_report_list = get_existing_nodes_names( lib_env.get_corosync_conf() ) if not node_list: get_nodes_report_list.append( - ReportItem.error(reports.messages.CorosyncConfigNoNodesDefined()) + reports.ReportItem.error( + reports.messages.CorosyncConfigNoNodesDefined() + ) ) if lib_env.report_processor.report_list(get_nodes_report_list).has_errors: raise LibraryError() @@ -349,7 +415,9 @@ def get_cluster_sbd_status(lib_env): return run_com(lib_env.get_node_communicator(), com_cmd) -def get_cluster_sbd_config(lib_env): +def get_cluster_sbd_config( + lib_env: LibraryEnvironment, +) -> list[dict[str, Optional[str]]]: """ Returns list of SBD config from all cluster nodes in cluster. Structure of data: @@ -362,15 +430,15 @@ def get_cluster_sbd_config(lib_env): ] If error occurs while obtaining config from some node, it's config will be None. If obtaining config fail on all node returns empty dictionary. - - lib_env -- LibraryEnvironment """ node_list, get_nodes_report_list = get_existing_nodes_names( lib_env.get_corosync_conf() ) if not node_list: get_nodes_report_list.append( - ReportItem.error(reports.messages.CorosyncConfigNoNodesDefined()) + reports.ReportItem.error( + reports.messages.CorosyncConfigNoNodesDefined() + ) ) if lib_env.report_processor.report_list(get_nodes_report_list).has_errors: raise LibraryError() @@ -384,28 +452,26 @@ def get_cluster_sbd_config(lib_env): return run_com(lib_env.get_node_communicator(), com_cmd) -def get_local_sbd_config(lib_env): +def get_local_sbd_config(lib_env: LibraryEnvironment) -> dict[str, str]: """ Returns local SBD config as dictionary. - - lib_env -- LibraryEnvironment """ del lib_env return environment_file_to_dict(sbd.get_local_sbd_config()) -def initialize_block_devices(lib_env, device_list, option_dict): +def initialize_block_devices( + lib_env: LibraryEnvironment, + device_list: StringSequence, + option_dict: Mapping[str, str], +) -> None: """ Initialize SBD devices in device_list with options_dict. - - lib_env -- LibraryEnvironment - device_list -- list of strings - option_dict -- dictionary """ report_item_list = [] if not device_list: report_item_list.append( - ReportItem.error( + reports.ReportItem.error( reports.messages.RequiredOptionsAreMissing(["device"]) ) ) @@ -427,7 +493,9 @@ def initialize_block_devices(lib_env, device_list, option_dict): ) -def get_local_devices_info(lib_env, dump=False): +def get_local_devices_info( + lib_env: LibraryEnvironment, dump: bool = False +) -> list[dict[str, Optional[str]]]: """ Returns list of local devices info in format: { @@ -437,13 +505,12 @@ def get_local_devices_info(lib_env, dump=False): } If sbd is not enabled, empty list will be returned. - lib_env -- LibraryEnvironment dump -- if True returns also output of command 'sbd dump' """ if not sbd.is_sbd_enabled(lib_env.service_manager): return [] device_list = sbd.get_local_sbd_device_list() - report_item_list = [] + report_item_list: reports.ReportItemList = [] output = [] for device in device_list: obj = { @@ -471,14 +538,15 @@ def get_local_devices_info(lib_env, dump=False): return output -def set_message(lib_env, device, node_name, message): +def set_message( + lib_env: LibraryEnvironment, device: str, node_name: str, message: str +) -> None: """ Set message on device for node_name. - lib_env -- LibraryEnvironment - device -- string, absolute path to device - node_name -- string - message -- string, message type, should be one of settings.sbd_message_types + device -- absolute path to device + node_name -- + message -- message type, should be one of settings.sbd_message_types """ report_item_list = [] missing_options = [] @@ -488,14 +556,14 @@ def set_message(lib_env, device, node_name, message): missing_options.append("node") if missing_options: report_item_list.append( - ReportItem.error( + reports.ReportItem.error( reports.messages.RequiredOptionsAreMissing(missing_options) ) ) supported_messages = settings.sbd_message_types if message not in supported_messages: report_item_list.append( - ReportItem.error( + reports.ReportItem.error( reports.messages.InvalidOptionValue( "message", message, supported_messages ) @@ -506,25 +574,26 @@ def set_message(lib_env, device, node_name, message): sbd.set_message(lib_env.cmd_runner(), device, node_name, message) -def get_local_available_watchdogs(lib_env): +def get_local_available_watchdogs( + lib_env: LibraryEnvironment, +) -> dict[str, dict[str, str]]: """ Returns available local watchdog devices. - - lib_env LibraryEnvironment """ return sbd.get_available_watchdogs(lib_env.cmd_runner()) -def test_local_watchdog(lib_env, watchdog=None): +def test_local_watchdog( + lib_env: LibraryEnvironment, watchdog: Optional[str] = None +) -> None: """ Test local watchdog device by triggering it. System reset is expected. If watchdog is not specified, available watchdog will be used if there is only one. - lib_env LibraryEnvironment - watchdog string -- watchdog to trigger + watchdog -- watchdog to trigger """ lib_env.report_processor.report( - ReportItem.info(reports.messages.SystemWillReset()) + reports.ReportItem.info(reports.messages.SystemWillReset()) ) sbd.test_watchdog(lib_env.cmd_runner(), watchdog) diff --git a/pcs/lib/commands/status.py b/pcs/lib/commands/status.py index f9ec6160b..05b1052ea 100644 --- a/pcs/lib/commands/status.py +++ b/pcs/lib/commands/status.py @@ -1,3 +1,4 @@ +import contextlib import os.path from typing import ( Iterable, @@ -45,6 +46,8 @@ from pcs.lib.node import get_existing_nodes_names from pcs.lib.node_communication import NodeTargetLibFactory from pcs.lib.pacemaker.live import ( + BadApiResultFormat, + get_cib_verification_errors, get_cluster_status_text, get_cluster_status_xml_raw, get_ticket_status_text, @@ -94,7 +97,7 @@ def resources_status(env: LibraryEnvironment) -> ResourcesStatusDto: return dto -def full_cluster_status_plaintext( +def full_cluster_status_plaintext( # noqa: PLR0912, PLR0915 env: LibraryEnvironment, hide_inactive_resources: bool = False, verbose: bool = False, @@ -146,6 +149,20 @@ def full_cluster_status_plaintext( if not live or os.path.exists(settings.corosync_conf_file): corosync_conf = env.get_corosync_conf() cib = env.get_cib() + # get messages from crm_verify + crm_verify_messages = [] + try: + crm_verify_messages = get_cib_verification_errors(runner) + except BadApiResultFormat as e: + # do not fail the whole command just because we cannot load this + report_processor.report( + reports.ReportItem.debug( + reports.messages.BadPcmkApiResponseFormat( + str(e.original_exception), e.pacemaker_response + ) + ) + ) + # get extra info for verbose output if verbose: ( ticket_status_text, @@ -155,12 +172,10 @@ def full_cluster_status_plaintext( # get extra info if live if live: service_manager = env.service_manager - try: + with contextlib.suppress(LibraryError): is_sbd_running = service_manager.is_running( get_sbd_service_name(service_manager) ) - except LibraryError: - pass local_services_status = _get_local_services_status(service_manager) if verbose and corosync_conf: node_name_list, node_names_report_list = get_existing_nodes_names( @@ -183,6 +198,7 @@ def full_cluster_status_plaintext( warning_list.extend( _booth_authfile_warning(env.report_processor, env.get_booth_env(None)) ) + warning_list.extend(crm_verify_messages) # put it all together if report_processor.has_errors: @@ -279,7 +295,7 @@ def _move_constraints_warnings( location_constraints, _ = get_all_as_dtos(constraint_el, rule_evaluator) - resource_ids = set( + resource_ids = { constraint_dto.resource_id for constraint_dto in location_constraints if constraint_dto.resource_id @@ -291,7 +307,7 @@ def _move_constraints_warnings( for rule in constraint_dto.attributes.rules ) ) - ) + } if resource_ids: warning_list.append( @@ -343,7 +359,7 @@ def _get_local_services_status( ] service_status_list = [] for service, display_always in service_def: - try: + with contextlib.suppress(LibraryError): service_status_list.append( _ServiceStatus( service, @@ -352,8 +368,6 @@ def _get_local_services_status( service_manager.is_running(service), ) ) - except LibraryError: - pass return service_status_list diff --git a/pcs/lib/commands/stonith.py b/pcs/lib/commands/stonith.py index d0799b8ea..c5c1ab4b3 100644 --- a/pcs/lib/commands/stonith.py +++ b/pcs/lib/commands/stonith.py @@ -1,11 +1,4 @@ -from typing import ( - Collection, - Container, - List, - Mapping, - Optional, - Tuple, -) +from typing import Collection, List, Mapping, Optional, Tuple from lxml.etree import _Element @@ -24,6 +17,7 @@ get_element_by_id, ) from pcs.lib.commands.resource import ( + _are_meta_disabled, _ensure_disabled_after_wait, resource_environment, ) @@ -102,13 +96,14 @@ def _get_agent_facade( raise LibraryError() from e -def create( +def create( # noqa: PLR0913 env: LibraryEnvironment, stonith_id: str, stonith_agent_name: str, operations: Collection[Mapping[str, str]], meta_attributes: Mapping[str, str], instance_attributes: Mapping[str, str], + *, allow_absent_agent: bool = False, allow_invalid_operation: bool = False, allow_invalid_instance_attributes: bool = False, @@ -158,8 +153,7 @@ def create( wait, [stonith_id], _ensure_disabled_after_wait( - ensure_disabled - or resource.common.are_meta_disabled(meta_attributes), + ensure_disabled or _are_meta_disabled(meta_attributes) ), ) as resources_section: id_provider = IdProvider(resources_section) @@ -184,7 +178,7 @@ def create( # DEPRECATED: this command is deprecated and will be removed in a future release -def create_in_group( +def create_in_group( # noqa: PLR0913 env: LibraryEnvironment, stonith_id: str, stonith_agent_name: str, @@ -192,6 +186,7 @@ def create_in_group( operations: Collection[Mapping[str, str]], meta_attributes: Mapping[str, str], instance_attributes: Mapping[str, str], + *, allow_absent_agent: bool = False, allow_invalid_operation: bool = False, allow_invalid_instance_attributes: bool = False, @@ -211,7 +206,7 @@ def create_in_group( env -- provides all for communication with externals stonith_id --an identifier of stonith resource stonith_agent_name -- contains name for the identification of agent - group_id -- identificator for group to put stonith inside + group_id -- identifier for group to put stonith inside operations -- contains attributes for each entered operation meta_attributes -- contains attributes for primitive/meta_attributes instance_attributes -- contains attributes for primitive/instance_attributes @@ -248,8 +243,7 @@ def create_in_group( wait, [stonith_id], _ensure_disabled_after_wait( - ensure_disabled - or resource.common.are_meta_disabled(meta_attributes), + ensure_disabled or _are_meta_disabled(meta_attributes) ), ) as resources_section: id_provider = IdProvider(resources_section) @@ -436,7 +430,7 @@ def _unfencing_scsi_devices( stonith_el: _Element, original_devices: StringCollection, updated_devices: StringCollection, - force_flags: Container[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: """ Unfence scsi devices provided in device_list if it is possible to connect @@ -497,7 +491,7 @@ def update_scsi_devices( env: LibraryEnvironment, stonith_id: str, set_device_list: StringCollection, - force_flags: Container[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: """ Update scsi fencing devices without restart and affecting other resources. @@ -543,7 +537,7 @@ def update_scsi_devices_add_remove( stonith_id: str, add_device_list: StringCollection, remove_device_list: StringCollection, - force_flags: Container[reports.types.ForceCode] = (), + force_flags: reports.types.ForceFlags = (), ) -> None: """ Update scsi fencing devices without restart and affecting other resources. diff --git a/pcs/lib/commands/tag.py b/pcs/lib/commands/tag.py index 393f47b7a..04b7f900b 100644 --- a/pcs/lib/commands/tag.py +++ b/pcs/lib/commands/tag.py @@ -7,6 +7,7 @@ from lxml.etree import _Element +from pcs.common.pacemaker.tag import CibTagListDto from pcs.common.types import StringSequence from pcs.lib.cib import tag from pcs.lib.cib.tools import ( @@ -50,9 +51,25 @@ def create( tag.create_tag(tags_section, tag_id, idref_list) +def _get_tag_elements( + env: LibraryEnvironment, tag_filter: StringSequence +) -> list[_Element]: + tags_section: _Element = get_tags(env.get_cib()) + + if not tag_filter: + return tag.get_list_of_tag_elements(tags_section) + + tag_element_list, report_list = tag.find_tag_elements_by_ids( + tags_section, + tag_filter, + ) + if env.report_processor.report_list(report_list).has_errors: + raise LibraryError() + return tag_element_list + + def config( - env: LibraryEnvironment, - tag_filter: StringSequence, + env: LibraryEnvironment, tag_filter: StringSequence ) -> list[dict[str, Union[str, list[str]]]]: """ Get tags specified in tag_filter or if empty, then get all the tags @@ -61,21 +78,33 @@ def config( env -- provides all for communication with externals tag_filter -- list of tags we want to get """ - tags_section: _Element = get_tags(env.get_cib()) - if tag_filter: - tag_element_list, report_list = tag.find_tag_elements_by_ids( - tags_section, - tag_filter, - ) - if env.report_processor.report_list(report_list).has_errors: - raise LibraryError() - else: - tag_element_list = tag.get_list_of_tag_elements(tags_section) + tag_element_list = _get_tag_elements(env, tag_filter) + return [ tag.tag_element_to_dict(tag_element) for tag_element in tag_element_list ] +def get_config_dto( + env: LibraryEnvironment, tag_filter: StringSequence +) -> CibTagListDto: + """ + Get tags specified in tag_filter or if empty, then get all the tags + configured. + + env -- provides all for communication with externals + tag_filter -- list of tags we want to get + """ + tag_element_list = _get_tag_elements(env, tag_filter) + + return CibTagListDto( + [ + tag.tag_element_to_dto(tag_element) + for tag_element in tag_element_list + ] + ) + + def remove(env: LibraryEnvironment, tag_list: StringSequence) -> None: """ Remove specified tags from a cib. diff --git a/pcs/lib/communication/cluster.py b/pcs/lib/communication/cluster.py index 4018dfae7..1e08f052d 100644 --- a/pcs/lib/communication/cluster.py +++ b/pcs/lib/communication/cluster.py @@ -1,8 +1,15 @@ +import json from typing import Optional +from dacite import DaciteError + from pcs.common import reports +from pcs.common.communication import const +from pcs.common.communication.dto import InternalCommunicationResultDto +from pcs.common.interface.dto import from_dict from pcs.common.node_communicator import RequestData from pcs.common.reports.item import ReportItem +from pcs.common.reports.processor import has_errors from pcs.lib.communication.tools import ( AllAtOnceStrategyMixin, AllSameDataMixin, @@ -129,3 +136,75 @@ def on_complete( self, ) -> tuple[Optional[bool], Optional[QuorumStatusFacade]]: return self._has_failure, self._quorum_status_facade + + +class RemoveCibClusterName( + SkipOfflineMixin, + AllSameDataMixin, + AllAtOnceStrategyMixin, + RunRemotelyBase, +): + def __init__(self, report_processor, skip_offline_targets=False): + super().__init__(report_processor) + self._set_skip_offline(skip_offline_targets) + + def before(self): + self._report( + reports.ReportItem.info( + reports.messages.CibClusterNameRemovalStarted() + ) + ) + + def _get_request_data(self): + return RequestData( + "api/v1/cluster-property-remove-name/v1", data=json.dumps({}) + ) + + def _process_response(self, response): + report_item = self._get_response_report(response) + if report_item: + self._report(report_item) + return + node_label = response.request.target.label + + try: + result = from_dict( + InternalCommunicationResultDto, json.loads(response.data) + ) + except (json.JSONDecodeError, DaciteError): + self._report( + reports.ReportItem.error( + reports.messages.InvalidResponseFormat(node_label) + ) + ) + return + + context = reports.ReportItemContext(node_label) + report_list = [ + reports.report_dto_to_item(report, context) + for report in result.report_list + ] + self._report_list(report_list) + + if ( + not has_errors(report_list) + and result.status == const.COM_STATUS_SUCCESS + ): + self._report( + reports.ReportItem.info( + reports.messages.CibClusterNameRemoved(node_label) + ) + ) + return + + # Make sure we report an error when the command was not successful + if result.status_msg or not has_errors(report_list): + self._report( + reports.ReportItem.error( + reports.messages.NodeCommunicationCommandUnsuccessful( + node_label, + response.request.action, + result.status_msg or "Unknown error", + ) + ) + ) diff --git a/pcs/lib/communication/nodes.py b/pcs/lib/communication/nodes.py index ae296ffcd..c8841f8ed 100644 --- a/pcs/lib/communication/nodes.py +++ b/pcs/lib/communication/nodes.py @@ -234,7 +234,7 @@ def before(self): self._start_report( [ self._action_key_to_report(key) - for key in self._action_definition.keys() + for key in self._action_definition ], [target.label for target in self._target_list], ) @@ -441,12 +441,11 @@ def _process_response(self, response): reports.messages.InvalidResponseFormat(target.label) ) - else: - if not response.was_connected: - self._not_yet_started_target_list.append(target) - report = response_to_report_item( - response, severity=ReportItemSeverity.WARNING - ) + elif not response.was_connected: + self._not_yet_started_target_list.append(target) + report = response_to_report_item( + response, severity=ReportItemSeverity.WARNING + ) self._report(report) def before(self): diff --git a/pcs/lib/corosync/config_facade.py b/pcs/lib/corosync/config_facade.py index 6ee8edc73..c76727412 100644 --- a/pcs/lib/corosync/config_facade.py +++ b/pcs/lib/corosync/config_facade.py @@ -11,16 +11,10 @@ from pcs import settings from pcs.common import reports from pcs.common.reports.item import ReportItem -from pcs.common.types import ( - StringCollection, - StringSequence, -) +from pcs.common.types import StringCollection, StringSequence from pcs.lib.corosync import constants from pcs.lib.corosync.config_parser import Section -from pcs.lib.corosync.node import ( - CorosyncNode, - CorosyncNodeAddress, -) +from pcs.lib.corosync.node import CorosyncNode, CorosyncNodeAddress from pcs.lib.errors import LibraryError from pcs.lib.interface.config import FacadeInterface @@ -105,6 +99,19 @@ def need_qdevice_reload(self) -> bool: def get_cluster_name(self) -> str: return self._get_option_value("totem", "cluster_name", "") + def set_cluster_name(self, new_name: str) -> None: + """ + Updates or adds cluster name + + new_name -- new name for the cluster + """ + self._need_stopped_cluster = True + totem_section_list = self.__ensure_section(self.config, "totem") + self.__set_section_options( + totem_section_list, {"cluster_name": new_name} + ) + self.__remove_empty_sections(self.config) + def get_cluster_uuid(self) -> Optional[str]: return self._get_option_value("totem", "cluster_uuid") @@ -242,6 +249,48 @@ def remove_nodes(self, node_name_list: StringCollection) -> None: self.__remove_empty_sections(self.config) self.__update_two_node() + def rename_node( + self, old_name: str, new_name: str + ) -> reports.ReportItemList: + """ + Rename a node in config + + old_name -- current node name + new_name -- new node name + """ + self._need_stopped_cluster = True + matching_node_addrs: dict[str, list[str]] = {} + for nodelist_section in self.config.get_sections("nodelist"): + for node_section in nodelist_section.get_sections("node"): + node_data = self._get_node_data(node_section) + node_ident = node_data.get("name", node_data.get("nodeid", "")) + + if node_data.get("name") == old_name: + node_section.set_attribute("name", new_name) + node_ident = new_name + + # Check for old name in address for all nodes. User should be + # notified if such address exists even if it is weird. + matching_addrs = [ + f"ring{i}_addr" + for i in range(constants.LINKS_MAX) + if node_data.get(f"ring{i}_addr") == old_name + ] + if matching_addrs: + matching_node_addrs[node_ident] = matching_addrs + + return ( + [ + ReportItem.warning( + reports.messages.CorosyncNodeRenameAddrsMatchOldName( + old_name, new_name, matching_node_addrs + ) + ) + ] + if matching_node_addrs + else [] + ) + def create_link_list(self, link_list: Sequence[Mapping[str, str]]) -> None: """ Add a link list to a config without one @@ -262,8 +311,8 @@ def create_link_list(self, link_list: Sequence[Mapping[str, str]]) -> None: else: linknumber_missing.append(link) - for link in linknumber_missing: - link = dict(link) + for link_missing_linknumber in linknumber_missing: + link = dict(link_missing_linknumber) try: link["linknumber"] = str(available_link_numbers.pop(0)) except IndexError as e: @@ -398,18 +447,17 @@ def update_link( del options_without_linknumber["linknumber"] # change options if options_without_linknumber: - target_interface_section_list = [] - for totem_section in self.config.get_sections("totem"): - for interface_section in totem_section.get_sections( - "interface" - ): - if ( - linknumber - == - # if no linknumber is set, corosync treats it as 0 - interface_section.get_attribute_value("linknumber", "0") - ): - target_interface_section_list.append(interface_section) + target_interface_section_list = [ + interface_section + for totem_section in self.config.get_sections("totem") + for interface_section in totem_section.get_sections("interface") + if ( + linknumber + == + # if no linknumber is set, corosync treats it as 0 + interface_section.get_attribute_value("linknumber", "0") + ) + ] self._set_link_options( options_without_linknumber, interface_section_list=target_interface_section_list, @@ -680,9 +728,13 @@ def get_quorum_device_settings( heuristics_options: dict[str, str] = {} for quorum in self.config.get_sections("quorum"): for device in quorum.get_sections("device"): - for name, value in device.get_attributes(): - if name != "model": - generic_options[name] = value + generic_options.update( + { + name: value + for name, value in device.get_attributes() + if name != "model" + } + ) for subsection in device.get_sections(): if subsection.name == "heuristics": heuristics_options.update(subsection.get_attributes()) @@ -735,10 +787,9 @@ def add_quorum_device( ) # configuration cleanup - remove_need_stopped_cluster = { - name: "" - for name in constants.QUORUM_OPTIONS_INCOMPATIBLE_WITH_QDEVICE - } + remove_need_stopped_cluster = dict.fromkeys( + constants.QUORUM_OPTIONS_INCOMPATIBLE_WITH_QDEVICE, "" + ) # remove old device settings quorum_section_list = self.__ensure_section(self.config, "quorum") for quorum in quorum_section_list: @@ -952,13 +1003,12 @@ def __translate_link_options( result["broadcast"] = "" else: del result["broadcast"] + # When displaying config to users, do the opposite + # transformation: only "yes" is allowed. + elif result["broadcast"] == "yes": + result["broadcast"] = "1" else: - # When displaying config to users, do the opposite - # transformation: only "yes" is allowed. - if result["broadcast"] == "yes": - result["broadcast"] = "1" - else: - del result["broadcast"] + del result["broadcast"] return result diff --git a/pcs/lib/corosync/config_parser.py b/pcs/lib/corosync/config_parser.py index 47a9cd180..799114c27 100644 --- a/pcs/lib/corosync/config_parser.py +++ b/pcs/lib/corosync/config_parser.py @@ -41,9 +41,7 @@ def empty(self) -> bool: return not self._attr_list and not self._section_list def export(self, indent: str = " ") -> str: - lines = [] - for attr in self._attr_list: - lines.append("{0}: {1}".format(*attr)) + lines = ["{0}: {1}".format(*attr) for attr in self._attr_list] if self._attr_list and self._section_list: lines.append("") section_count = len(self._section_list) @@ -128,7 +126,7 @@ def add_section(self, section: "Section") -> "Section": section.parent.del_section(section) # here we are editing obj's _parent attribute of the same class # pylint: disable=protected-access - section._parent = self + section._parent = self # noqa: SLF001 self._section_list.append(section) return self @@ -138,7 +136,7 @@ def del_section(self, section: "Section") -> "Section": # thanks to remove raising a ValueError in that case # here we are editing obj's _parent attribute of the same class # pylint: disable=protected-access - section._parent = None + section._parent = None # noqa: SLF001 return self def __str__(self) -> str: @@ -160,6 +158,7 @@ def exception_to_report_list( force_code: Optional[reports.types.ForceCode], is_forced_or_warning: bool, ) -> reports.ReportItemList: + del file_type_code, file_path, force_code, is_forced_or_warning # TODO switch to new exceptions / reports and do not ignore input # arguments of the function return [ diff --git a/pcs/lib/corosync/config_validators.py b/pcs/lib/corosync/config_validators.py index 3b3f8aa24..19f9bdf47 100644 --- a/pcs/lib/corosync/config_validators.py +++ b/pcs/lib/corosync/config_validators.py @@ -92,7 +92,7 @@ def _validate_value(self, value: validate.ValuePair) -> ReportItemList: return [] -def create( +def create( # noqa: PLR0912, PLR0915 cluster_name: str, # TODO change to DTO, needs new validator node_list: Iterable[Mapping[str, Any]], @@ -117,20 +117,7 @@ def create( warnings instead of errors """ # cluster name and transport validation - validators = [ - validate.ValueNotEmpty( - "name", None, option_name_for_report="cluster name" - ), - _ClusterNameGfs2Validator( - "name", - option_name_for_report="cluster name", - severity=reports.item.get_severity( - reports.codes.FORCE, force_cluster_name - ), - ), - validate.ValueCorosyncValue( - "name", option_name_for_report="cluster name" - ), + validators = _get_cluster_name_validators(force_cluster_name) + [ validate.ValueIn("transport", constants.TRANSPORTS_ALL), validate.ValueCorosyncValue("transport"), ] @@ -286,6 +273,26 @@ def create( return report_items +def _get_cluster_name_validators( + force_cluster_name: bool, +) -> list[validate.ValidatorInterface]: + return [ + validate.ValueNotEmpty( + "name", None, option_name_for_report="cluster name" + ), + _ClusterNameGfs2Validator( + "name", + option_name_for_report="cluster name", + severity=reports.item.get_severity( + reports.codes.FORCE, force_cluster_name + ), + ), + validate.ValueCorosyncValue( + "name", option_name_for_report="cluster name" + ), + ] + + def _get_node_name_validators( node_index: int, ) -> list[validate.ValidatorInterface]: @@ -402,7 +409,7 @@ def _report_unresolvable_addresses_if_any( ] -def add_nodes( +def add_nodes( # noqa: PLR0912, PLR0915 # TODO change to DTO, needs new validator node_list: Iterable[Mapping[str, Any]], coro_existing_nodes: Iterable[CorosyncNode], @@ -622,11 +629,11 @@ def remove_nodes( quorum_device_settings -- model, generic and heuristic qdevice options """ existing_node_names = [node.name for node in existing_nodes] - report_items = [] - for not_found_node in set(nodes_names_to_remove) - set(existing_node_names): - report_items.append( - ReportItem.error(reports.messages.NodeNotFound(not_found_node)) - ) + report_items = [ + ReportItem.error(reports.messages.NodeNotFound(not_found_node)) + for not_found_node in set(nodes_names_to_remove) + - set(existing_node_names) + ] if not set(existing_node_names) - set(nodes_names_to_remove): report_items.append( @@ -637,20 +644,20 @@ def remove_nodes( qdevice_model_options, _, _ = quorum_device_settings tie_breaker_nodeid = qdevice_model_options.get("tie_breaker") if tie_breaker_nodeid not in [None, "lowest", "highest"]: - for node in existing_nodes: + report_items.extend( + ReportItem.error( + reports.messages.NodeUsedAsTieBreaker( + node.name, node.nodeid + ) + ) + for node in existing_nodes if ( node.name in nodes_names_to_remove and # "4" != 4, convert ids to string to detect a match for sure str(node.nodeid) == str(tie_breaker_nodeid) - ): - report_items.append( - ReportItem.error( - reports.messages.NodeUsedAsTieBreaker( - node.name, node.nodeid - ) - ) - ) + ) + ) return report_items @@ -833,6 +840,8 @@ def _get_link_options_validators_knet( validate.ValueNonnegativeInteger("ping_timeout"), validate.ValueNonnegativeInteger("pong_count"), validate.ValueIn("transport", ("sctp", "udp")), + # DEPRECATED in knet 1, to be removed in knet 2.0 + validate.ValueDeprecated("transport", {"sctp": None}), ] if including_linknumber: @@ -861,9 +870,9 @@ def _get_link_options_validators_knet( ) -def _get_link_options_validators_knet_relations() -> ( - list[validate.ValidatorInterface] -): +def _get_link_options_validators_knet_relations() -> list[ + validate.ValidatorInterface +]: return [ validate.DependsOnOption( ["ping_interval"], @@ -912,9 +921,7 @@ def _update_link_options_knet( ) ).validate(new_options) + validate.ValidatorAll( _get_link_options_validators_knet_relations() - ).validate( - after_update - ) + ).validate(after_update) def add_link( @@ -1140,7 +1147,7 @@ def remove_links( return report_items -def update_link( +def update_link( # noqa: PLR0912, PLR0913 linknumber: str, node_addr_map: Mapping[str, str], link_options: Mapping[str, str], @@ -1155,6 +1162,7 @@ def update_link( # pylint: disable=too-many-arguments # pylint: disable=too-many-branches # pylint: disable=too-many-locals + # pylint: disable=too-many-positional-arguments """ Validate changing an existing link @@ -2038,7 +2046,7 @@ def _get_qdevice_generic_options_validators( def _split_heuristics_exec_options( - options: Mapping[str, str] + options: Mapping[str, str], ) -> tuple[dict[str, str], dict[str, str]]: options_exec = {} options_nonexec = {} @@ -2162,3 +2170,11 @@ def _mixes_ipv4_ipv6(addr_types: Collection[CorosyncNodeAddressType]) -> bool: return {CorosyncNodeAddressType.IPV4, CorosyncNodeAddressType.IPV6} <= set( addr_types ) + + +def rename_cluster( + cluster_name: str, force_cluster_name: bool = False +) -> ReportItemList: + return validate.ValidatorAll( + _get_cluster_name_validators(force_cluster_name) + ).validate({"name": cluster_name}) diff --git a/pcs/lib/corosync/live.py b/pcs/lib/corosync/live.py index 0295fc266..23f3d1d7f 100644 --- a/pcs/lib/corosync/live.py +++ b/pcs/lib/corosync/live.py @@ -175,7 +175,7 @@ def stopping_local_node_cause_quorum_loss(self) -> bool: ) -def _parse_quorum_status(quorum_status: str) -> QuorumStatus: +def _parse_quorum_status(quorum_status: str) -> QuorumStatus: # noqa: PLR0912 # pylint: disable=too-many-branches node_list: list[QuorumStatusNode] = [] qdevice_list: list[QuorumStatusNode] = [] @@ -184,12 +184,12 @@ def _parse_quorum_status(quorum_status: str) -> QuorumStatus: in_node_list = False try: - for line in quorum_status.splitlines(): - line = line.strip() + for quorum_status_line in quorum_status.splitlines(): + line = quorum_status_line.strip() if not line: continue if in_node_list: - if line.startswith("-") or line.startswith("Nodeid"): + if line.startswith(("-", "Nodeid")): # skip headers continue parts = line.split() @@ -215,7 +215,7 @@ def _parse_quorum_status(quorum_status: str) -> QuorumStatus: if line == "Membership information": in_node_list = True continue - if not ":" in line: + if ":" not in line: continue parts = [x.strip() for x in line.split(":", 1)] if parts[0] == "Quorate": diff --git a/pcs/lib/corosync/qdevice_net.py b/pcs/lib/corosync/qdevice_net.py index 6e623b452..e22ae533f 100644 --- a/pcs/lib/corosync/qdevice_net.py +++ b/pcs/lib/corosync/qdevice_net.py @@ -2,19 +2,13 @@ import os.path import re import shutil -from typing import ( - Callable, - Optional, - Sequence, -) +from typing import Callable, Optional, Sequence from pcs import settings from pcs.common import reports -from pcs.common.node_communicator import ( - NodeCommunicatorFactory, - RequestTarget, -) +from pcs.common.node_communicator import NodeCommunicatorFactory, RequestTarget from pcs.common.str_tools import join_multilines +from pcs.common.tools import format_os_error from pcs.common.types import StringSequence from pcs.lib.communication import qdevice_net as qdevice_net_com from pcs.lib.communication.tools import run_and_raise @@ -142,10 +136,12 @@ def qdevice_destroy() -> None: try: if qdevice_initialized(): shutil.rmtree(settings.corosync_qdevice_net_server_certs_dir) - except EnvironmentError as e: + except OSError as e: raise LibraryError( reports.ReportItem.error( - reports.messages.QdeviceDestroyError(__model, e.strerror) + reports.messages.QdeviceDestroyError( + __model, format_os_error(e) + ) ) ) from e @@ -261,10 +257,10 @@ def qdevice_sign_certificate_request( cluster_name, ] ) - except EnvironmentError as e: + except OSError as e: raise LibraryError( reports.ReportItem.error( - reports.messages.QdeviceCertificateSignError(e.strerror) + reports.messages.QdeviceCertificateSignError(format_os_error(e)) ) ) from e if retval != 0: @@ -301,12 +297,11 @@ def client_setup(runner: CommandRunner, ca_certificate: bytes) -> None: ) with open(ca_file_path, "wb") as ca_file: ca_file.write(ca_certificate) - except EnvironmentError as e: + except OSError as e: raise LibraryError( reports.ReportItem.error( reports.messages.QdeviceInitializationError( - __model, - e.strerror, + __model, format_os_error(e) ) ) ) from e @@ -346,10 +341,12 @@ def client_destroy() -> None: try: if client_initialized(): shutil.rmtree(settings.corosync_qdevice_net_client_certs_dir) - except EnvironmentError as e: + except OSError as e: raise LibraryError( reports.ReportItem.error( - reports.messages.QdeviceDestroyError(__model, e.strerror) + reports.messages.QdeviceDestroyError( + __model, format_os_error(e) + ) ) ) from e @@ -421,10 +418,12 @@ def client_cert_request_to_pk12( tmpfile.name, ] ) - except EnvironmentError as e: + except OSError as e: raise LibraryError( reports.ReportItem.error( - reports.messages.QdeviceCertificateImportError(e.strerror) + reports.messages.QdeviceCertificateImportError( + format_os_error(e) + ) ) ) from e if retval != 0: @@ -464,10 +463,12 @@ def client_import_certificate_and_key( tmpfile.name, ] ) - except EnvironmentError as e: + except OSError as e: raise LibraryError( reports.ReportItem.error( - reports.messages.QdeviceCertificateImportError(e.strerror) + reports.messages.QdeviceCertificateImportError( + format_os_error(e) + ) ) ) from e if retval != 0: @@ -504,9 +505,7 @@ def _get_output_certificate( try: with open(filename, "rb") as cert_file: return cert_file.read() - except EnvironmentError as e: + except OSError as e: raise LibraryError( - reports.ReportItem.error( - report_message_func(f"{filename}: {e.strerror}") - ) + reports.ReportItem.error(report_message_func(format_os_error(e))) ) from e diff --git a/pcs/lib/env.py b/pcs/lib/env.py index abf2c2b6d..d0cffc993 100644 --- a/pcs/lib/env.py +++ b/pcs/lib/env.py @@ -76,7 +76,7 @@ def _wait_type_to_int(wait: WaitType) -> int: is returned. If None, wait is enabled without timeout, therefore 0 is returned. If string representing timeout or positive integer, wait is enabled with timeout, therefore number of seconds is - retuned. Otherwise a LibraryError is raised. + returned. Otherwise a LibraryError is raised. """ if wait is False: return -1 @@ -90,7 +90,7 @@ class LibraryEnvironment: # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods - def __init__( + def __init__( # noqa: PLR0913 self, logger: Logger, report_processor: reports.ReportProcessor, @@ -105,6 +105,7 @@ def __init__( request_timeout: Optional[int] = None, ): # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments self._logger = logger self._report_processor = report_processor self._user_login = user_login diff --git a/pcs/lib/external.py b/pcs/lib/external.py index a84c1c934..d3bacd84c 100644 --- a/pcs/lib/external.py +++ b/pcs/lib/external.py @@ -2,18 +2,14 @@ import subprocess from logging import Logger from shlex import quote as shell_quote -from typing import ( - Dict, - Mapping, - Optional, - Tuple, -) +from typing import Dict, Mapping, Optional, Tuple from pcs import settings from pcs.common import reports from pcs.common.reports import ReportProcessor from pcs.common.reports.item import ReportItem from pcs.common.str_tools import join_multilines +from pcs.common.tools import format_os_error from pcs.common.types import StringSequence from pcs.lib.errors import LibraryError @@ -60,31 +56,29 @@ def run( env_vars.update(dict(env_extend) if env_extend else {}) log_args = " ".join([shell_quote(x) for x in args]) - self._logger.debug( - "Running: {args}\nEnvironment:{env_vars}{stdin_string}".format( - args=log_args, - stdin_string=( - "" - if not stdin_string - else ( - "\n--Debug Input Start--\n{0}\n--Debug Input End--" - ).format(stdin_string) - ), - env_vars=( - "" - if not env_vars - else ( - "\n" - + "\n".join( - [ - " {0}={1}".format(key, val) - for key, val in sorted(env_vars.items()) - ] - ) - ) - ), + env = ( + "" + if not env_vars + else ( + "\n" + + "\n".join( + [ + " {0}={1}".format(key, val) + for key, val in sorted(env_vars.items()) + ] + ) ) ) + stdin = ( + "" + if not stdin_string + else ("\n--Debug Input Start--\n{0}\n--Debug Input End--").format( + stdin_string + ) + ) + self._logger.debug( + "Running: %s\nEnvironment:%s%s", log_args, env, stdin + ) self._reporter.report( ReportItem.debug( reports.messages.RunExternalProcessStarted( @@ -108,7 +102,7 @@ def run( ), stdout=subprocess.PIPE, stderr=subprocess.PIPE, - preexec_fn=( + preexec_fn=( # noqa: PLW1509 lambda: signal.signal(signal.SIGPIPE, signal.SIG_DFL) ), close_fds=True, @@ -123,20 +117,21 @@ def run( raise LibraryError( ReportItem.error( reports.messages.RunExternalProcessError( - log_args, - e.strerror, + log_args, format_os_error(e) ) ) ) from e self._logger.debug( ( - "Finished running: {args}\nReturn value: {retval}" - + "\n--Debug Stdout Start--\n{out_std}\n--Debug Stdout End--" - + "\n--Debug Stderr Start--\n{out_err}\n--Debug Stderr End--" - ).format( - args=log_args, retval=retval, out_std=out_std, out_err=out_err - ) + "Finished running: %s\nReturn value: %s" + "\n--Debug Stdout Start--\n%s\n--Debug Stdout End--" + "\n--Debug Stderr Start--\n%s\n--Debug Stderr End--" + ), + log_args, + retval, + out_std, + out_err, ) self._reporter.report( ReportItem.debug( @@ -171,7 +166,7 @@ def kill_services(runner, services): raise KillServicesError(list(services), message) -def is_proxy_set(env_dict): +def is_proxy_set(env_dict: Mapping) -> bool: """ Returns True whenever any of proxy environment variables (https_proxy, HTTPS_PROXY, all_proxy, ALL_PROXY) are set in env_dict. False otherwise. diff --git a/pcs/lib/file/instance.py b/pcs/lib/file/instance.py index 9794f1a9f..055e1bcf7 100644 --- a/pcs/lib/file/instance.py +++ b/pcs/lib/file/instance.py @@ -14,8 +14,8 @@ from pcs.lib.file import ( metadata, raw_file, - toolbox, ) +from pcs.lib.file import toolbox as file_toolbox from pcs.lib.interface.config import ( FacadeInterface, ParserErrorException, @@ -78,7 +78,7 @@ def _for_booth( ghost_file, ghost_data, ), - toolbox.for_file_type(file_type_code), + file_toolbox.for_file_type(file_type_code), ) @classmethod @@ -131,13 +131,13 @@ def _for_common( ) -> "FileInstance": return cls( raw_file.RealFile(metadata.for_file_type(file_type_code)), - toolbox.for_file_type(file_type_code), + file_toolbox.for_file_type(file_type_code), ) def __init__( self, raw_file_interface: RawFileInterface, - file_toolbox: toolbox.FileToolbox, + file_toolbox: file_toolbox.FileToolbox, ): """ Factories should be used instead @@ -156,7 +156,7 @@ def raw_file(self) -> RawFileInterface: return self._raw_file @property - def toolbox(self) -> toolbox.FileToolbox: + def toolbox(self) -> file_toolbox.FileToolbox: """ Get the underlying FileToolbox instance """ diff --git a/pcs/lib/file/metadata.py b/pcs/lib/file/metadata.py index 2ba188d0a..a01ff4a61 100644 --- a/pcs/lib/file/metadata.py +++ b/pcs/lib/file/metadata.py @@ -112,7 +112,7 @@ def _for_pcs_settings_conf() -> FileMetadata: ) -def for_file_type( +def for_file_type( # noqa: PLR0911 file_type_code: code.FileTypeCode, filename: Optional[str] = None ) -> FileMetadata: # pylint: disable=too-many-return-statements diff --git a/pcs/lib/file/raw_file.py b/pcs/lib/file/raw_file.py index 55707abd7..05529aa96 100644 --- a/pcs/lib/file/raw_file.py +++ b/pcs/lib/file/raw_file.py @@ -13,12 +13,12 @@ # places # pylint: disable=unused-import from pcs.common import reports -from pcs.common.file import FileMetadata -from pcs.common.file import RawFile as RealFile from pcs.common.file import ( + FileMetadata, RawFileError, RawFileInterface, ) +from pcs.common.file import RawFile as RealFile # noqa: F401 # TODO add logging (logger / debug reports ?) @@ -85,6 +85,7 @@ def read(self) -> bytes: return self.__file_data def write(self, file_data: bytes, can_overwrite: bool = False) -> None: + del can_overwrite self.__file_data = file_data @contextmanager diff --git a/pcs/lib/file/toolbox.py b/pcs/lib/file/toolbox.py index 2fe856c53..09d5d6863 100644 --- a/pcs/lib/file/toolbox.py +++ b/pcs/lib/file/toolbox.py @@ -57,6 +57,13 @@ def exception_to_report_list( force_code: Optional[reports.types.ForceCode], is_forced_or_warning: bool, ) -> reports.ReportItemList: + del ( + exception, + file_type_code, + file_path, + force_code, + is_forced_or_warning, + ) return [] diff --git a/pcs/lib/node.py b/pcs/lib/node.py index aa40a59bd..3dd215fb7 100644 --- a/pcs/lib/node.py +++ b/pcs/lib/node.py @@ -43,7 +43,7 @@ def get_pacemaker_node_names(cib: _Element) -> Set[str]: def get_existing_nodes_names_addrs( corosync_conf=None, cib=None, error_on_missing_name=False -): +) -> tuple[list[str], list[str], ReportItemList]: corosync_nodes, remote_and_guest_nodes = __get_nodes(corosync_conf, cib) names, report_list = __get_nodes_names( corosync_nodes, remote_and_guest_nodes, error_on_missing_name @@ -106,7 +106,10 @@ def __get_nodes_names( ) -def __get_nodes_addrs(corosync_nodes, remote_and_guest_nodes): +def __get_nodes_addrs( + corosync_nodes: Iterable[CorosyncNode], + remote_and_guest_nodes: Iterable[PacemakerNode], +) -> list[str]: nodes_addrs = [node.addr for node in remote_and_guest_nodes] for node in corosync_nodes: nodes_addrs += node.addrs_plain() diff --git a/pcs/lib/node_communication.py b/pcs/lib/node_communication.py index 0db7be9c6..6a1a4b9cc 100644 --- a/pcs/lib/node_communication.py +++ b/pcs/lib/node_communication.py @@ -97,10 +97,12 @@ def _log_debug(self, response): url = response.request.url debug_data = response.debug self._logger.debug( - f"Communication debug info for calling: {url}\n" + "Communication debug info for calling: %s\n" "--Debug Communication Info Start--\n" - f"{debug_data}\n" - "--Debug Communication Info End--" + "%s\n" + "--Debug Communication Info End--", + url, + debug_data, ) self._reporter.report( ReportItem.debug( @@ -260,16 +262,15 @@ def response_to_report_item( elif response_code >= 400: report_item = reports.messages.NodeCommunicationError reason = f"HTTP error: {response_code}" + elif response.errno in [ + pycurl.E_OPERATION_TIMEDOUT, + pycurl.E_OPERATION_TIMEOUTED, + ]: + report_item = reports.messages.NodeCommunicationErrorTimedOut + reason = response.error_msg else: - if response.errno in [ - pycurl.E_OPERATION_TIMEDOUT, - pycurl.E_OPERATION_TIMEOUTED, - ]: - report_item = reports.messages.NodeCommunicationErrorTimedOut - reason = response.error_msg - else: - report_item = reports.messages.NodeCommunicationErrorUnableToConnect - reason = response.error_msg + report_item = reports.messages.NodeCommunicationErrorUnableToConnect + reason = response.error_msg if not report_item: return None return ReportItem( diff --git a/pcs/lib/node_communication_format.py b/pcs/lib/node_communication_format.py index fc06ad707..d38808c5e 100644 --- a/pcs/lib/node_communication_format.py +++ b/pcs/lib/node_communication_format.py @@ -102,6 +102,8 @@ def service_cmd_format(service, command): class Result(namedtuple("Result", "code message")): """Wrapper over some call results""" + __slots__ = () + def unpack_items_from_response(main_response, main_key, node_label): """ diff --git a/pcs/lib/pacemaker/live.py b/pcs/lib/pacemaker/live.py index 301ce3436..da1154f53 100644 --- a/pcs/lib/pacemaker/live.py +++ b/pcs/lib/pacemaker/live.py @@ -1,11 +1,9 @@ import os.path import re from typing import ( - Dict, - List, Mapping, Optional, - Tuple, + Union, cast, ) @@ -36,6 +34,7 @@ from pcs.lib.resource_agent import ResourceAgentName from pcs.lib.xml_tools import etree_to_str +__EXITCODE_INVALID_CIB = 78 __EXITCODE_NOT_CONNECTED = 102 __EXITCODE_CIB_SCOPE_VALID_BUT_NOT_PRESENT = 105 __EXITCODE_WAIT_TIMEOUT = 124 @@ -50,15 +49,19 @@ class FenceHistoryCommandErrorException(Exception): pass +class BadApiResultFormat(Exception): + def __init__(self, original_exception: Exception, pacemaker_response: str): + self.original_exception = original_exception + self.pacemaker_response = pacemaker_response + + ### status -def get_cluster_status_xml_raw(runner: CommandRunner) -> Tuple[str, str, int]: +def get_cluster_status_xml_raw(runner: CommandRunner) -> tuple[str, str, int]: """ Run pacemaker tool to get XML status. This function doesn't do any processing. Usually, using get_cluster_status_dom is preferred instead. - - runner -- a class for running external processes """ return runner.run( [ @@ -74,8 +77,6 @@ def get_cluster_status_xml_raw(runner: CommandRunner) -> Tuple[str, str, int]: def _get_cluster_status_xml(runner: CommandRunner) -> str: """ Get pacemaker XML status. Using get_cluster_status_dom is preferred instead. - - runner -- a class for running external processes """ stdout, stderr, retval = get_cluster_status_xml_raw(runner) if retval == 0: @@ -114,7 +115,7 @@ def get_cluster_status_text( runner: CommandRunner, hide_inactive_resources: bool, verbose: bool, -) -> Tuple[str, List[str]]: +) -> tuple[str, list[str]]: cmd = [settings.crm_mon_exec, "--one-shot"] if not hide_inactive_resources: cmd.append("--inactive") @@ -132,7 +133,7 @@ def get_cluster_status_text( reports.messages.CrmMonError(join_multilines([stderr, stdout])) ) ) - warnings: List[str] = [] + warnings: list[str] = [] if stderr.strip(): warnings = [ line @@ -143,7 +144,7 @@ def get_cluster_status_text( return stdout.strip(), warnings -def get_ticket_status_text(runner: CommandRunner) -> Tuple[str, str, int]: +def get_ticket_status_text(runner: CommandRunner) -> tuple[str, str, int]: stdout, stderr, retval = runner.run([settings.crm_ticket_exec, "--details"]) return stdout.strip(), stderr.strip(), retval @@ -151,6 +152,10 @@ def get_ticket_status_text(runner: CommandRunner) -> Tuple[str, str, int]: ### cib +def has_cib_xml() -> bool: + return os.path.exists(os.path.join(settings.cib_dir, "cib.xml")) + + def get_cib_xml_cmd_results( runner: CommandRunner, scope: Optional[str] = None ) -> tuple[str, str, int]: @@ -180,6 +185,10 @@ def get_cib_xml(runner: CommandRunner, scope: Optional[str] = None) -> str: return stdout +def get_cib_file_runner_env() -> dict[str, str]: + return {"CIB_file": os.path.join(settings.cib_dir, "cib.xml")} + + def parse_cib_xml(xml: str) -> _Element: return xml_fromstring(xml) @@ -193,23 +202,35 @@ def get_cib(xml: str) -> _Element: ) from e -def verify(runner, verbose=False): +def _run_crm_verify( + runner: CommandRunner, xml_output: bool = False, verbose: bool = False +) -> tuple[str, str, int]: crm_verify_cmd = [settings.crm_verify_exec] # Currently, crm_verify can suggest up to two -V options but it accepts # more than two. We stick with two -V options if verbose mode was enabled. if verbose: crm_verify_cmd.extend(["-V", "-V"]) - # With the `crm_verify` command it is not possible simply use the - # environment variable CIB_file because `crm_verify` simply tries to - # connect to cib file via tool that can fail because: Update does not - # conform to the configured schema + if xml_output: + crm_verify_cmd.extend(["--output-as", "xml"]) + # With the `crm_verify` command it is not possible to simply use the + # environment variable CIB_file because `crm_verify` tries to connect to + # cib file via tool that can fail because: Update does not conform to the + # configured schema # So we use the explicit flag `--xml-file`. cib_tmp_file = runner.env_vars.get("CIB_file", None) if cib_tmp_file is None: crm_verify_cmd.append("--live-check") else: crm_verify_cmd.extend(["--xml-file", cib_tmp_file]) - stdout, stderr, returncode = runner.run(crm_verify_cmd) + return runner.run(crm_verify_cmd) + + +def verify( + runner: CommandRunner, verbose: bool = False +) -> tuple[str, str, int, bool]: + stdout, stderr, returncode = _run_crm_verify( + runner, xml_output=False, verbose=verbose + ) can_be_more_verbose = False if returncode != 0: # remove lines with -V options @@ -229,7 +250,27 @@ def verify(runner, verbose=False): return stdout, stderr, returncode, can_be_more_verbose -def replace_cib_configuration_xml(runner, xml): +def get_cib_verification_errors(runner: CommandRunner) -> list[str]: + # Uses XML output of crm_verify which is easier to work with. Verbose mode + # is not needed, it only adds debug messages outside of the XML. We don't + # need to filter out hints to add more -V to increase verbosity, as they + # are not printed by crm_verify in XML output mode. + + # in case of invalid configuration, returncode != 0 - it cannot be used to + # determine whether the command succeeded or failed + stdout, stderr, returncode = _run_crm_verify( + runner, xml_output=True, verbose=False + ) + try: + api_status = _get_status_from_api_result(_get_api_result_dom(stdout)) + if api_status.code == __EXITCODE_INVALID_CIB: + return list(api_status.errors) + return [] + except (etree.XMLSyntaxError, etree.DocumentInvalid) as e: + raise BadApiResultFormat(e, join_multilines([stderr, stdout])) from e + + +def replace_cib_configuration_xml(runner: CommandRunner, xml: str) -> None: cmd = [ settings.cibadmin_exec, "--replace", @@ -245,11 +286,11 @@ def replace_cib_configuration_xml(runner, xml): ) -def replace_cib_configuration(runner, tree): +def replace_cib_configuration(runner: CommandRunner, tree: _Element) -> None: return replace_cib_configuration_xml(runner, etree_to_str(tree)) -def push_cib_diff_xml(runner, cib_diff_xml): +def push_cib_diff_xml(runner: CommandRunner, cib_diff_xml: str) -> None: cmd = [ settings.cibadmin_exec, "--patch", @@ -272,8 +313,6 @@ def diff_cibs_xml( """ Return xml diff of two CIBs - runner - reporter cib_old_xml -- original CIB cib_new_xml -- modified CIB """ @@ -313,21 +352,13 @@ def ensure_cib_version( cib: _Element, version: Version, fail_if_version_not_met: bool = True, -) -> Tuple[_Element, bool]: +) -> tuple[_Element, bool]: """ Make sure CIB complies to specified schema version (or newer), upgrade CIB if necessary. Raise on error. Raise if CIB cannot be upgraded enough to meet the required version unless fail_if_version_not_met is set to False. Return tuple(upgraded_cib, was_upgraded) - This method ensures that specified cib is verified by pacemaker with - version 'version' or newer. If cib doesn't correspond to this version, - method will try to upgrade cib. - Returns cib which was verified by pacemaker version 'version' or later. - Raises LibraryError on any failure. - - runner -- runner - cib -- cib tree version -- required cib version fail_if_version_not_met -- allows a 'nice to have' cib upgrade """ @@ -360,10 +391,9 @@ def ensure_cib_version( ) -def _upgrade_cib(runner): +def _upgrade_cib(runner: CommandRunner) -> None: """ Upgrade CIB to the latest schema available locally or clusterwise. - CommandRunner runner """ stdout, stderr, retval = runner.run( [settings.cibadmin_exec, "--upgrade", "--force"] @@ -381,12 +411,13 @@ def _upgrade_cib(runner): ) -def simulate_cib_xml(runner, cib_xml): +def simulate_cib_xml( + runner: CommandRunner, cib_xml: str +) -> tuple[str, str, str]: """ Run crm_simulate to get effects the cib would have on the live cluster - CommandRunner runner -- runner - string cib_xml -- CIB XML to simulate + cib_xml -- CIB XML to simulate """ try: with ( @@ -422,12 +453,13 @@ def simulate_cib_xml(runner, cib_xml): ) from e -def simulate_cib(runner, cib): +def simulate_cib( + runner: CommandRunner, cib: _Element +) -> tuple[str, _Element, _Element]: """ Run crm_simulate to get effects the cib would have on the live cluster - CommandRunner runner -- runner - etree cib -- cib tree to simulate + cib -- cib tree to simulate """ cib_xml = etree_to_str(cib) try: @@ -452,9 +484,7 @@ def wait_for_idle(runner: CommandRunner, timeout: int) -> None: """ Run waiting command. Raise LibraryError if command failed. - runner -- preconfigured object for running external programs - timeout -- waiting timeout in seconds, wait indefinitely if non-positive - integer + timeout -- waiting timeout in seconds, wait indefinitely if less than 1 """ args = [settings.crm_resource_exec, "--wait"] if timeout > 0: @@ -484,7 +514,7 @@ def wait_for_idle(runner: CommandRunner, timeout: int) -> None: ### nodes -def get_local_node_name(runner): +def get_local_node_name(runner: CommandRunner) -> str: stdout, stderr, retval = runner.run([settings.crm_node_exec, "--name"]) if retval != 0: klass = ( @@ -502,7 +532,7 @@ def get_local_node_name(runner): return stdout.strip() -def get_local_node_status(runner): +def get_local_node_status(runner: CommandRunner) -> dict[str, Union[bool, str]]: try: cluster_status = ClusterState(get_cluster_status_dom(runner)) node_name = get_local_node_name(runner) @@ -510,7 +540,7 @@ def get_local_node_status(runner): return {"offline": True} for node_status in cluster_status.node_section.nodes: if node_status.attrs.name == node_name: - result = { + result: dict[str, Union[bool, str]] = { "offline": False, } for attr in ( @@ -535,7 +565,7 @@ def get_local_node_status(runner): ) -def remove_node(runner, node_name): +def remove_node(runner: CommandRunner, node_name: str) -> None: stdout, stderr, retval = runner.run( [ settings.crm_node_exec, @@ -558,6 +588,37 @@ def remove_node(runner, node_name): ### resources +def resource_restart( + runner: CommandRunner, + resource: str, + node: Optional[str] = None, + timeout: Optional[str] = None, +) -> None: + """ + Ask pacemaker to restart a resource + + resource -- id of the resource to be restarted + node -- name of the node to limit the restart to + timeout -- abort if the command doesn't finish in this time (integer + unit) + """ + cmd = [settings.crm_resource_exec, "--restart", "--resource", resource] + if node: + cmd.extend(["--node", node]) + if timeout: + cmd.extend(["--timeout", timeout]) + + stdout, stderr, retval = runner.run(cmd) + + if retval != 0: + raise LibraryError( + ReportItem.error( + reports.messages.ResourceRestartError( + join_multilines([stderr, stdout]), resource, node + ) + ) + ) + + def resource_cleanup( runner: CommandRunner, resource: Optional[str] = None, @@ -565,7 +626,7 @@ def resource_cleanup( operation: Optional[str] = None, interval: Optional[str] = None, strict: bool = False, -): +) -> str: cmd = [settings.crm_resource_exec, "--cleanup"] if resource: cmd.extend(["--resource", resource]) @@ -598,7 +659,7 @@ def resource_refresh( node: Optional[str] = None, strict: bool = False, force: bool = False, -): +) -> str: if not force and not node and not resource: summary = ClusterState(get_cluster_status_dom(runner)).summary operations = summary.nodes.attrs.count * summary.resources.attrs.count @@ -634,7 +695,13 @@ def resource_refresh( return join_multilines([stdout, stderr]) -def resource_move(runner, resource_id, node=None, master=False, lifetime=None): +def resource_move( + runner: CommandRunner, + resource_id: str, + node: Optional[str] = None, + master: bool = False, + lifetime: Optional[str] = None, +) -> tuple[str, str, int]: return _resource_move_ban_clear( runner, "--move", @@ -645,7 +712,13 @@ def resource_move(runner, resource_id, node=None, master=False, lifetime=None): ) -def resource_ban(runner, resource_id, node=None, master=False, lifetime=None): +def resource_ban( + runner: CommandRunner, + resource_id: str, + node: Optional[str] = None, + master: bool = False, + lifetime: Optional[str] = None, +) -> tuple[str, str, int]: return _resource_move_ban_clear( runner, "--ban", @@ -657,8 +730,12 @@ def resource_ban(runner, resource_id, node=None, master=False, lifetime=None): def resource_unmove_unban( - runner, resource_id, node=None, master=False, expired=False -): + runner: CommandRunner, + resource_id: str, + node: Optional[str] = None, + master: bool = False, + expired: bool = False, +) -> tuple[str, str, int]: return _resource_move_ban_clear( runner, "--clear", @@ -669,21 +746,21 @@ def resource_unmove_unban( ) -def has_resource_unmove_unban_expired_support(runner): +def has_resource_unmove_unban_expired_support(runner: CommandRunner) -> bool: return _is_in_pcmk_tool_help( runner, settings.crm_resource_exec, ["--expired"] ) def _resource_move_ban_clear( - runner, - action, - resource_id, - node=None, - master=False, - lifetime=None, - expired=False, -): + runner: CommandRunner, + action: str, + resource_id: str, + node: Optional[str] = None, + master: bool = False, + lifetime: Optional[str] = None, + expired: bool = False, +) -> tuple[str, str, int]: command = [ settings.crm_resource_exec, action, @@ -719,22 +796,28 @@ def is_fence_history_supported_management(runner: CommandRunner) -> bool: ) -def fence_history_cleanup(runner, node=None): +def fence_history_cleanup( + runner: CommandRunner, node: Optional[str] = None +) -> str: return _run_fence_history_command(runner, "--cleanup", node) -def fence_history_text(runner, node=None): +def fence_history_text( + runner: CommandRunner, node: Optional[str] = None +) -> str: return _run_fence_history_command(runner, "--verbose", node) -def fence_history_update(runner): +def fence_history_update(runner: CommandRunner) -> str: # Pacemaker always prints "gather fencing-history from all nodes" even if a # node is specified. However, --history expects a value, so we must provide # it. Otherwise "--broadcast" would be considered a value of "--history". return _run_fence_history_command(runner, "--broadcast", node=None) -def _run_fence_history_command(runner, command, node=None): +def _run_fence_history_command( + runner: CommandRunner, command: str, node: Optional[str] = None +) -> str: stdout, stderr, retval = runner.run( [ settings.stonith_admin_exec, @@ -750,6 +833,55 @@ def _run_fence_history_command(runner, command, node=None): return stdout.strip() +### tickets + + +def ticket_standby( + cmd_runner: CommandRunner, ticket_name: str +) -> tuple[str, str, int]: + """ + Change state of the ticket to standby + + ticket_name -- name of the ticket + """ + return cmd_runner.run( + [settings.crm_ticket_exec, "--standby", "--ticket", ticket_name] + ) + + +def ticket_cleanup( + cmd_runner: CommandRunner, ticket_name: str +) -> tuple[str, str, int]: + """ + Delete all state of the ticket from the CIB. + + ticket_name -- name of the ticket + """ + return cmd_runner.run( + [ + settings.crm_ticket_exec, + "--cleanup", + "--force", + "--ticket", + ticket_name, + ] + ) + + +def ticket_unstandby( + cmd_runner: CommandRunner, ticket_name: str +) -> tuple[str, str, int]: + """ + Change state of the ticket to active + + ticket_name -- name of the ticket + """ + + return cmd_runner.run( + [settings.crm_ticket_exec, "--activate", "--ticket", ticket_name] + ) + + ### tools @@ -827,9 +959,9 @@ def _is_in_pcmk_tool_help( ) -def is_getting_resource_digest_supported(runner): +def is_getting_resource_digest_supported(runner: CommandRunner) -> bool: return _is_in_pcmk_tool_help( - runner, settings.crm_resource_exec, "--digests" + runner, settings.crm_resource_exec, ["--digests"] ) @@ -837,15 +969,14 @@ def get_resource_digests( runner: CommandRunner, resource_id: str, node_name: str, - resource_options: Dict[str, str], - crm_meta_attributes: Optional[Dict[str, Optional[str]]] = None, -) -> Dict[str, Optional[str]]: + resource_options: dict[str, str], + crm_meta_attributes: Optional[dict[str, Optional[str]]] = None, +) -> dict[str, Optional[str]]: """ Get set of digests for a resource using crm_resource utility. There are 3 types of digests: all, nonreloadable and nonprivate. Resource can have one or more digests types depending on the resource parameters. - runner -- command runner instance resource_id -- resource id node_name -- name of the node where resource is running resource_options -- resource options with updated values @@ -872,7 +1003,7 @@ def get_resource_digests( ] stdout, stderr, retval = runner.run(command) - def error_exception(message): + def error_exception(message: str) -> LibraryError: return LibraryError( ReportItem.error( reports.messages.UnableToGetResourceOperationDigests(message) @@ -893,7 +1024,7 @@ def error_exception(message): digests = {} for digest_type in ["all", "nonprivate", "nonreloadable"]: xpath_result = cast( - List[str], + list[str], dom.xpath( "./digests/digest[@type=$digest_type]/@hash", digest_type=digest_type, diff --git a/pcs/lib/pacemaker/state.py b/pcs/lib/pacemaker/state.py index 10f29af4e..d4ba21783 100644 --- a/pcs/lib/pacemaker/state.py +++ b/pcs/lib/pacemaker/state.py @@ -41,7 +41,7 @@ def __init__(self, owner_name, attrib, required_attrs): self.required_attrs = required_attrs def __getattr__(self, name): - if name in self.required_attrs.keys(): + if name in self.required_attrs: try: attr_specification = self.required_attrs[name] if isinstance(attr_specification, tuple): @@ -67,7 +67,7 @@ def __init__(self, owner_name, dom_part, children, sections): self.sections = sections def __getattr__(self, name): - if name in self.children.keys(): + if name in self.children: element_name, wrapper = self.children[name] return [ wrapper(element) @@ -76,7 +76,7 @@ def __getattr__(self, name): ) ] - if name in self.sections.keys(): + if name in self.sections: element_name, wrapper = self.sections[name] return wrapper( self.dom_part.xpath( @@ -266,9 +266,7 @@ def is_resource_managed(cluster_state, resource_id): .//resource[{predicate_id}] | .//group[{predicate_id}]/resource - """.format( - predicate_id=_id_xpath_predicate - ), + """.format(predicate_id=_id_xpath_predicate), id=resource_id, ) if primitive_list: diff --git a/pcs/lib/pacemaker/status.py b/pcs/lib/pacemaker/status.py index 9b0930507..067012847 100644 --- a/pcs/lib/pacemaker/status.py +++ b/pcs/lib/pacemaker/status.py @@ -26,6 +26,7 @@ from pcs.common.str_tools import format_list from pcs.lib.pacemaker.values import is_true +_DEFAULT_SEVERITY = reports.ReportItemSeverity.error() _PRIMITIVE_TAG = "resource" _GROUP_TAG = "group" _CLONE_TAG = "clone" @@ -39,7 +40,7 @@ def __init__(self, resource_id: str): class EmptyResourceIdError(ClusterStatusParsingError): - def __init__(self): + def __init__(self) -> None: super().__init__("") @@ -104,7 +105,7 @@ def __init__(self, bundle_id: str, bad_ids: list[str]): def cluster_status_parsing_error_to_report( e: ClusterStatusParsingError, - severity: reports.ReportItemSeverity = reports.ReportItemSeverity.error(), + severity: reports.ReportItemSeverity = _DEFAULT_SEVERITY, ) -> reports.ReportItem: reason = "" if isinstance(e, EmptyResourceIdError): @@ -115,8 +116,7 @@ def cluster_status_parsing_error_to_report( ) elif isinstance(e, UnknownPcmkRoleError): reason = ( - f"Resource '{e.resource_id}' contains an unknown " - f"role '{e.role}'" + f"Resource '{e.resource_id}' contains an unknown role '{e.role}'" ) elif isinstance(e, UnexpectedMemberError): reason = ( @@ -251,14 +251,14 @@ def _clone_to_dto( raise MixedMembersError(clone_id) if primitive_list: - if len(set(res.resource_id for res in primitive_list)) > 1: + if len({res.resource_id for res in primitive_list}) > 1: raise DifferentMemberIdsError(clone_id) if group_list: - group_ids = set(group.resource_id for group in group_list) - children_ids = set( + group_ids = {group.resource_id for group in group_list} + children_ids = { tuple(child.resource_id for child in group.members) for group in group_list - ) + } if len(group_ids) > 1 or len(children_ids) > 1: raise DifferentMemberIdsError(clone_id) @@ -383,9 +383,10 @@ def _get_target_role(resource: _Element) -> Optional[PcmkRoleType]: target_role = resource.get("target_role") if target_role is None: return None - if target_role not in PCMK_ROLES: + target_role_normalized = target_role.capitalize() + if target_role_normalized not in PCMK_ROLES: raise UnknownPcmkRoleError(str(resource.get("id")), target_role) - return PcmkRoleType(target_role) + return PcmkRoleType(target_role_normalized) def _remove_clone_suffix(resource_id: str) -> tuple[str, Optional[str]]: @@ -409,8 +410,8 @@ def _replica_to_dto( ] duplicate_ids = [ - id - for id, count in Counter( + id_ + for id_, count in Counter( resource.resource_id for resource in resource_list ).items() if count > 1 diff --git a/pcs/lib/pacemaker/values.py b/pcs/lib/pacemaker/values.py index 18025e215..087970d41 100644 --- a/pcs/lib/pacemaker/values.py +++ b/pcs/lib/pacemaker/values.py @@ -12,6 +12,7 @@ ReportItemList, ) from pcs.common.tools import timeout_to_seconds +from pcs.common.validate import is_integer from pcs.lib.errors import LibraryError from pcs.lib.external import CommandRunner @@ -60,7 +61,7 @@ def is_score(value: str) -> bool: if not value: return False unsigned_value = value[1:] if value[0] in ("+", "-") else value - return unsigned_value == SCORE_INFINITY or unsigned_value.isdigit() + return unsigned_value == SCORE_INFINITY or is_integer(value) def is_duration(runner: CommandRunner, value: str) -> bool: @@ -89,11 +90,29 @@ def get_valid_timeout_seconds( return wait_timeout +def validate_id_reports( + id_candidate: str, + description: Optional[str] = None, +) -> reports.ReportItemList: + """ + Validate a pacemaker id, return ReportItemList + + id_candidate id's value + description id's role description (default "id") + """ + # This is a temporary improvement of validate_id function. It turns it into + # a function which returns a ReportItemList. When validate_id is removed, + # this function can be removed as well. + report_list: ReportItemList = [] + validate_id(id_candidate, description, report_list) + return report_list + + def validate_id( id_candidate: str, description: Optional[str] = None, reporter: Union[None, List, ReportItemList] = None, -): +) -> None: """ Validate a pacemaker id, raise LibraryError on invalid id. @@ -103,7 +122,7 @@ def validate_id( # see NCName definition # http://www.w3.org/TR/REC-xml-names/#NT-NCName # http://www.w3.org/TR/REC-xml/#NT-Name - description = "id" if not description else description # for mypy + description = description if description else "id" # for mypy if not id_candidate: report_item = ReportItem.error( reports.messages.InvalidIdIsEmpty(description) @@ -143,6 +162,7 @@ def validate_id( def sanitize_id(id_candidate: str, replacement: str = "") -> str: + # TODO move this to pcs.lib.cib.tools? if not id_candidate: return id_candidate return "".join( diff --git a/pcs/lib/resource_agent/error.py b/pcs/lib/resource_agent/error.py index 6ae02af95..5d827d423 100644 --- a/pcs/lib/resource_agent/error.py +++ b/pcs/lib/resource_agent/error.py @@ -3,6 +3,8 @@ from . import const +_DEFAULT_SEVERITY = reports.ReportItemSeverity.error() + class ResourceAgentError(Exception): def __init__(self, agent_name: str): @@ -44,7 +46,7 @@ def __init__(self, agent_name: str, ocf_version: str): def resource_agent_error_to_report_item( e: ResourceAgentError, - severity: reports.ReportItemSeverity = reports.ReportItemSeverity.error(), + severity: reports.ReportItemSeverity = _DEFAULT_SEVERITY, is_stonith: bool = False, ) -> reports.ReportItem: """ diff --git a/pcs/lib/resource_agent/list.py b/pcs/lib/resource_agent/list.py index 4eb449d56..a4e6672af 100644 --- a/pcs/lib/resource_agent/list.py +++ b/pcs/lib/resource_agent/list.py @@ -141,15 +141,13 @@ def _find_all_resource_agents_by_type( type_ -- last part of an agent name """ type_lower = type_.lower() - possible_names = [] - for std_provider in list_resource_agents_standards_and_providers(runner): - for existing_type in list_resource_agents(runner, std_provider): - if type_lower == existing_type.lower(): - possible_names.append( - ResourceAgentName( - std_provider.standard, - std_provider.provider, - existing_type, - ) - ) - return possible_names + return [ + ResourceAgentName( + std_provider.standard, + std_provider.provider, + existing_type, + ) + for std_provider in list_resource_agents_standards_and_providers(runner) + for existing_type in list_resource_agents(runner, std_provider) + if type_lower == existing_type.lower() + ] diff --git a/pcs/lib/resource_agent/ocf_transform.py b/pcs/lib/resource_agent/ocf_transform.py index 8c8a85f6b..29a9920e6 100644 --- a/pcs/lib/resource_agent/ocf_transform.py +++ b/pcs/lib/resource_agent/ocf_transform.py @@ -23,7 +23,7 @@ def ocf_version_to_ocf_unified( - metadata: Union[ResourceAgentMetadataOcf1_0, ResourceAgentMetadataOcf1_1] + metadata: Union[ResourceAgentMetadataOcf1_0, ResourceAgentMetadataOcf1_1], ) -> ResourceAgentMetadata: """ Transform specific version OCF metadata to a universal format @@ -116,30 +116,28 @@ def _ocf_1_0_parameter_list_to_ocf_unified( if parameter.obsoletes: deprecated_by_dict[parameter.obsoletes].add(parameter.name) - result = [] - for parameter in parameter_list: - result.append( - ResourceAgentParameter( - name=parameter.name, - shortdesc=parameter.shortdesc, - longdesc=parameter.longdesc, - type=parameter.type, - default=parameter.default, - enum_values=parameter.enum_values, - required=_bool_value(parameter.required), - advanced=False, - deprecated=_bool_value(parameter.deprecated), - deprecated_by=sorted(deprecated_by_dict[parameter.name]), - deprecated_desc=None, - unique_group=( - f"{const.DEFAULT_UNIQUE_GROUP_PREFIX}{parameter.name}" - if _bool_value(parameter.unique) - else None - ), - reloadable=_bool_value(parameter.unique), - ) + return [ + ResourceAgentParameter( + name=parameter.name, + shortdesc=parameter.shortdesc, + longdesc=parameter.longdesc, + type=parameter.type, + default=parameter.default, + enum_values=parameter.enum_values, + required=_bool_value(parameter.required), + advanced=False, + deprecated=_bool_value(parameter.deprecated), + deprecated_by=sorted(deprecated_by_dict[parameter.name]), + deprecated_desc=None, + unique_group=( + f"{const.DEFAULT_UNIQUE_GROUP_PREFIX}{parameter.name}" + if _bool_value(parameter.unique) + else None + ), + reloadable=_bool_value(parameter.unique), ) - return result + for parameter in parameter_list + ] def _ocf_1_1_parameter_list_to_ocf_unified( diff --git a/pcs/lib/resource_agent/pcs_transform.py b/pcs/lib/resource_agent/pcs_transform.py index 14022c87b..7760ae24b 100644 --- a/pcs/lib/resource_agent/pcs_transform.py +++ b/pcs/lib/resource_agent/pcs_transform.py @@ -143,10 +143,13 @@ def _parameter_extract_advanced_from_desc( # strings. If either the strings are present OR the xml attribute is 1, the # parameter is advanced and the behavior is the same in both cases: remove # plaintext representation of structured data. - advanced_str_beginings = ["Advanced use only:", "*** Advanced Use Only ***"] + advanced_str_beginnings = [ + "Advanced use only:", + "*** Advanced Use Only ***", + ] shortdesc = parameter.shortdesc if shortdesc: - for advanced_str in advanced_str_beginings: + for advanced_str in advanced_str_beginnings: if shortdesc.startswith(advanced_str): new_shortdesc = shortdesc.removeprefix(advanced_str).lstrip() return replace( @@ -231,7 +234,7 @@ def _metadata_make_stonith_port_parameter_not_required( # parameter (defined in fenced metadata). Therefore, we must mark 'port' # and all parameters replacing it as not required. port_related_params = set() - next_iteration_params = set(["port"]) + next_iteration_params = {"port"} while next_iteration_params: current_params = next_iteration_params next_iteration_params = set() diff --git a/pcs/lib/resource_agent/types.py b/pcs/lib/resource_agent/types.py index 68d07b6a4..53ec08081 100644 --- a/pcs/lib/resource_agent/types.py +++ b/pcs/lib/resource_agent/types.py @@ -38,6 +38,10 @@ def is_pcmk_fake_agent(self) -> bool: def is_stonith(self) -> bool: return self.standard == "stonith" + @property + def is_ocf(self) -> bool: + return self.standard == "ocf" + def to_dto(self) -> ResourceAgentNameDto: return ResourceAgentNameDto( standard=self.standard, @@ -287,7 +291,7 @@ def provides_self_validation(self) -> bool: @property def provides_promotability(self) -> bool: - return set(action.name for action in self.actions) >= { + return {action.name for action in self.actions} >= { "promote", "demote", } diff --git a/pcs/lib/sbd.py b/pcs/lib/sbd.py index b6e69d4bc..7f8cbf611 100644 --- a/pcs/lib/sbd.py +++ b/pcs/lib/sbd.py @@ -1,6 +1,7 @@ import re from os import path from typing import ( + Mapping, Optional, Union, ) @@ -8,10 +9,12 @@ from pcs import settings from pcs.common import reports from pcs.common.services.interfaces import ServiceManagerInterface +from pcs.common.types import StringSequence from pcs.common.validate import is_integer from pcs.lib import validate from pcs.lib.corosync.config_facade import ConfigFacade as CorosyncConfFacade from pcs.lib.errors import LibraryError +from pcs.lib.external import CommandRunner from pcs.lib.services import is_systemd from pcs.lib.tools import ( dict_to_environment_file, @@ -64,8 +67,8 @@ def _get_allowed_values(self) -> None: def _even_number_of_nodes_and_no_qdevice( - corosync_conf_facade, node_number_modifier=0 -): + corosync_conf_facade: CorosyncConfFacade, node_number_modifier: int = 0 +) -> bool: """ Returns True whenever cluster has no quorum device configured and number of nodes + node_number_modifier is even number, False otherwise. @@ -107,7 +110,9 @@ def is_auto_tie_breaker_needed( ) -def atb_has_to_be_enabled_pre_enable_check(corosync_conf_facade): +def atb_has_to_be_enabled_pre_enable_check( + corosync_conf_facade: CorosyncConfFacade, +) -> bool: """ Returns True whenever quorum option auto_tie_breaker is needed to be enabled for proper working of SBD fencing. False if it is not needed. This function @@ -146,11 +151,13 @@ def atb_has_to_be_enabled( ) -def validate_new_nodes_devices(nodes_devices): +def validate_new_nodes_devices( + nodes_devices: Mapping[str, StringSequence], +) -> reports.ReportItemList: """ Validate if SBD devices are set for new nodes when they should be - dict nodes_devices -- name: node name, key: list of SBD devices + nodes_devices -- name: node name, key: list of SBD devices """ if _is_device_set_local(): return validate_nodes_devices( @@ -166,16 +173,16 @@ def validate_new_nodes_devices(nodes_devices): def validate_nodes_devices( - node_device_dict, adding_nodes_to_sbd_enabled_cluster=False -): + node_device_dict: Mapping[str, StringSequence], + adding_nodes_to_sbd_enabled_cluster: bool = False, +) -> reports.ReportItemList: """ Validates device list for all nodes. If node is present, it checks if there is at least one device and at max settings.sbd_max_device_num. Also devices have to be specified with absolute path. - Returns list of ReportItem - dict node_device_dict -- name: node name, key: list of SBD devices - bool adding_nodes_to_sbd_enabled_cluster -- provides context to reports + node_device_dict -- name: node name, key: list of SBD devices + adding_nodes_to_sbd_enabled_cluster -- provides context to reports """ report_item_list = [] for node_label, device_list in node_device_dict.items(): @@ -194,23 +201,28 @@ def validate_nodes_devices( report_item_list.append( reports.ReportItem.error( reports.messages.SbdTooManyDevicesForNode( - node_label, device_list, settings.sbd_max_device_num + node_label, + list(device_list), + settings.sbd_max_device_num, ) ) ) - for device in device_list: - if not device or not path.isabs(device): - report_item_list.append( - reports.ReportItem.error( - reports.messages.SbdDevicePathNotAbsolute( - device, node_label - ) - ) - ) + report_item_list.extend( + reports.ReportItem.error( + reports.messages.SbdDevicePathNotAbsolute(device, node_label) + ) + for device in device_list + if not device or not path.isabs(device) + ) return report_item_list -def create_sbd_config(base_config, node_label, watchdog, device_list=None): +def create_sbd_config( + base_config: Mapping[str, str], + node_label: str, + watchdog: str, + device_list: Optional[StringSequence] = None, +) -> str: # TODO: figure out which name/ring has to be in SBD_OPTS config = dict(base_config) config["SBD_OPTS"] = f'"-n {node_label}"' @@ -222,7 +234,7 @@ def create_sbd_config(base_config, node_label, watchdog, device_list=None): return dict_to_environment_file(config) -def get_default_sbd_config(): +def get_default_sbd_config() -> dict[str, str]: """ Returns default SBD configuration as dictionary. """ @@ -237,9 +249,7 @@ def get_default_sbd_config(): def get_local_sbd_config() -> str: """ - Get local SBD configuration. - - Raises LibraryError on any failure. + Get local SBD configuration. Raise LibraryError on any failure. """ try: with open(settings.sbd_config, "r") as sbd_cfg: @@ -267,29 +277,25 @@ def is_sbd_enabled(service_manager: ServiceManagerInterface) -> bool: def is_sbd_installed(service_manager: ServiceManagerInterface) -> bool: """ Check if SBD service is installed in local system. - Reurns True id SBD service is installed. False otherwise. + Returns True id SBD service is installed. False otherwise. """ return service_manager.is_installed(get_sbd_service_name(service_manager)) def initialize_block_devices( report_processor: reports.ReportProcessor, - cmd_runner, - device_list, - option_dict, -): + cmd_runner: CommandRunner, + device_list: StringSequence, + option_dict: Mapping[str, str], +) -> None: """ Initialize devices with specified options in option_dict. Raise LibraryError on failure. - report_processor -- report processor - cmd_runner -- CommandRunner - device_list -- list of strings - option_dict -- dictionary of options and their values """ report_processor.report( reports.ReportItem.info( - reports.messages.SbdDeviceInitializationStarted(device_list) + reports.messages.SbdDeviceInitializationStarted(list(device_list)) ) ) @@ -306,18 +312,18 @@ def initialize_block_devices( raise LibraryError( reports.ReportItem.error( reports.messages.SbdDeviceInitializationError( - device_list, std_err + list(device_list), std_err ) ) ) report_processor.report( reports.ReportItem.info( - reports.messages.SbdDeviceInitializationSuccess(device_list) + reports.messages.SbdDeviceInitializationSuccess(list(device_list)) ) ) -def get_local_sbd_device_list(): +def get_local_sbd_device_list() -> list[str]: """ Returns list of devices specified in local SBD config """ @@ -341,12 +347,9 @@ def _is_device_set_local() -> bool: return len(get_local_sbd_device_list()) > 0 -def get_device_messages_info(cmd_runner, device): +def get_device_messages_info(cmd_runner: CommandRunner, device: str) -> str: """ Returns info about messages (string) stored on specified SBD device. - - cmd_runner -- CommandRunner - device -- string """ std_out, dummy_std_err, ret_val = cmd_runner.run( [settings.sbd_exec, "-d", device, "list"] @@ -361,12 +364,9 @@ def get_device_messages_info(cmd_runner, device): return std_out -def get_device_sbd_header_dump(cmd_runner, device): +def get_device_sbd_header_dump(cmd_runner: CommandRunner, device: str) -> str: """ Returns header dump (string) of specified SBD device. - - cmd_runner -- CommandRunner - device -- string """ std_out, dummy_std_err, ret_val = cmd_runner.run( [settings.sbd_exec, "-d", device, "dump"] @@ -403,8 +403,6 @@ def validate_stonith_watchdog_timeout( ) -> reports.ReportItemList: """ Check sbd status and config when user is setting stonith-watchdog-timeout - Returns error message if the value is unacceptable, otherwise return nothing - to set the property stonith_watchdog_timeout -- value to be validated """ @@ -444,14 +442,15 @@ def validate_stonith_watchdog_timeout( ).validate({"stonith-watchdog-timeout": stonith_watchdog_timeout}) -def set_message(cmd_runner, device, node_name, message): +def set_message( + cmd_runner: CommandRunner, device: str, node_name: str, message: str +) -> None: """ Set message of specified type 'message' on SBD device for node. - cmd_runner -- CommandRunner - device -- string, device path - node_name -- string, name of node for which message should be set - message -- string, message type + device -- device path + node_name -- name of node for which message should be set + message -- message type """ dummy_std_out, std_err, ret_val = cmd_runner.run( [settings.sbd_exec, "-d", device, "message", node_name, message] @@ -466,7 +465,9 @@ def set_message(cmd_runner, device, node_name, message): ) -def get_available_watchdogs(cmd_runner): +def get_available_watchdogs( + cmd_runner: CommandRunner, +) -> dict[str, dict[str, str]]: regex = ( r"\[\d+\] (?P.+)$\n" r"Identity: (?P.+)$\n" @@ -490,7 +491,9 @@ def get_available_watchdogs(cmd_runner): } -def test_watchdog(cmd_runner, watchdog=None): +def test_watchdog( + cmd_runner: CommandRunner, watchdog: Optional[str] = None +) -> None: cmd = [settings.sbd_exec, "test-watchdog"] if watchdog: cmd.extend(["-w", watchdog]) diff --git a/pcs/lib/sbd_stonith.py b/pcs/lib/sbd_stonith.py new file mode 100644 index 000000000..42879971d --- /dev/null +++ b/pcs/lib/sbd_stonith.py @@ -0,0 +1,142 @@ +from typing import Optional + +from lxml.etree import _Element + +from pcs.common import reports +from pcs.common.types import StringCollection +from pcs.lib.cib.resource.common import get_parent_resource +from pcs.lib.cib.resource.common import is_disabled as is_resource_disabled +from pcs.lib.cib.resource.stonith import get_all_node_isolating_resources +from pcs.lib.communication.sbd import GetSbdStatus +from pcs.lib.communication.tools import run as run_communication +from pcs.lib.env import LibraryEnvironment +from pcs.lib.node import get_existing_nodes_names + + +def ensure_some_stonith_remains( + env: LibraryEnvironment, + resources_el: _Element, + stonith_resources_to_ignore: StringCollection, + sbd_being_disabled: bool, + force_flags: reports.types.ForceFlags, +) -> reports.ReportItemList: + """ + Error out when no sbd or enabled stonith would be left after a config change + + resource_el -- cib element holding resources + stonith_resources_to_ignore -- ids of stonith being removed, disabled, etc. + sbd_being_disabled -- ignore working sbd as it is being disabled + force_flags -- use to emit a warning instead of an error + """ + # Checking whether sbd is enabled requires communicating with other cluster + # nodes, which may bring additional issues, like nodes not being + # accessible. To reduce clutter in reports, check for the sbd being enabled + # only when necessary. + + if not stonith_resources_to_ignore and not sbd_being_disabled: + # No stonith resources are being removed or disabled and SBD is not + # being disabled either. There is no change in cluster fencing + # capabilities and therefore nothing to report. + return [] + + current_stonith = [] + for stonith_el in get_all_node_isolating_resources(resources_el): + # If any nvset disables the resource, even with a rule to limit it to + # specific time, then the resource wouldn't be able to fence all the + # time and should be considered disabled. + # However, pcs currently supports only one nvset for meta attributes, + # so we only check that to be consistent. Checking all nvsets could + # lead to a situation not resolvable by pcs, as pcs doesn't allow to + # change other nvsets than the first one. + # Stonith resources can be disabled by their parent clones or groups, + # so check them as well. + # The check is not perfect, but it is a reasonable effort, considering + # that multiple nvsets are not supported for meta attributes by pcs now. + # It can be improved when a need for it raises. + resource_tree = [] + element: Optional[_Element] = stonith_el + while element is not None: + resource_tree.append(element) + element = get_parent_resource(element) + if all(not is_resource_disabled(res_el) for res_el in resource_tree): + current_stonith.append(stonith_el) + + stonith_left = [ + stonith_el + for stonith_el in current_stonith + if stonith_el.attrib["id"] not in stonith_resources_to_ignore + ] + + if stonith_left: + # Working stonith devices will be present. No need to check for SBD. + return [] + + # No stonith in the cluster, need to check SBD. + current_sbd_active = _is_sbd_active_on_any_node(env) + sbd_left_active = current_sbd_active and not sbd_being_disabled + + if sbd_left_active: + # SBD will be left enabled. + return [] + + if not current_stonith and not current_sbd_active: + # Now we know that no enabled stonith will be left in the cluster and + # sbd will also be disabled. However, if that already was the case, we + # don't produce an error -> the cluster already cannot fence, saying + # that it is a result of the current change would not be true. + return [] + + return [ + reports.ReportItem( + reports.get_severity( + reports.codes.FORCE, reports.codes.FORCE in force_flags + ), + reports.messages.NoStonithMeansWouldBeLeft(), + ) + ] + + +def _is_sbd_active_on_any_node(env: LibraryEnvironment) -> bool: + # SBD can be enabled only partially in the cluster. Even when that is the + # case, we warn the user when disabling it. For example, SBD can be enabled + # for full stack nodes and disabled for remote / guest nodes. + if not env.is_cib_live: + # We cannot tell whether sbd is enabled or not, as we do not have + # access to a cluster. Expect sbd is not enabled. + return False + + # Do not return errors. The check should not prevent deleting a resource + # just because a node in a cluster is temporarily unavailable. We do our + # best to figure out sbd status. + node_list, get_nodes_report_list = get_existing_nodes_names( + env.get_corosync_conf() + ) + env.report_processor.report_list(get_nodes_report_list) + if not node_list: + env.report_processor.report( + reports.ReportItem.warning( + reports.messages.CorosyncConfigNoNodesDefined() + ) + ) + return False + + com_cmd = GetSbdStatus(env.report_processor) + com_cmd.set_targets( + env.get_node_target_factory().get_target_list( + node_list, skip_non_existing=True + ) + ) + response_per_node = run_communication(env.get_node_communicator(), com_cmd) + for response in response_per_node: + # Values can be either True (== sbd is enabled), or False (== sbd is + # disabled), or None (== unknown, not connected). + # We do not want to block removing resources just because we were + # temporarily unable to connect to a node. + # If sbd is enabled and not running, then the cluster won't have any + # fencing after removing all stonith resources. If sbd is not enabled + # and running, then the cluster won't have any fencing after removing + # all stonith resources and rebooting nodes once. So we need both + # enabled and running to be true to consider sbd as active. + if response["status"]["enabled"] and response["status"]["running"]: + return True + return False diff --git a/pcs/lib/services.py b/pcs/lib/services.py index fcfb548c4..3a712c624 100644 --- a/pcs/lib/services.py +++ b/pcs/lib/services.py @@ -59,12 +59,15 @@ def kill(self, service: str, instance: Optional[str] = None) -> None: self._warn(service, instance, reports.const.SERVICE_ACTION_KILL) def is_enabled(self, service: str, instance: Optional[str] = None) -> bool: + del service, instance return False def is_running(self, service: str, instance: Optional[str] = None) -> bool: + del service, instance return False def is_installed(self, service: str) -> bool: + del service return True def get_available_services(self) -> List[str]: diff --git a/pcs/lib/tools.py b/pcs/lib/tools.py index 97137ebd9..e945c30ac 100644 --- a/pcs/lib/tools.py +++ b/pcs/lib/tools.py @@ -53,8 +53,8 @@ def environment_file_to_dict(config: str) -> dict[str, str]: config = config.replace("\\\n", "") data = {} - for line in [l.strip() for l in config.split("\n")]: - if line == "" or line.startswith("#") or line.startswith(";"): + for line in [line.strip() for line in config.split("\n")]: + if line == "" or line.startswith(("#", ";")): continue if "=" not in line: continue @@ -138,7 +138,7 @@ def create_tmp_cib( ) -> IO[str]: try: # pylint: disable=consider-using-with - tmp_file = tempfile.NamedTemporaryFile(mode="w+", suffix=".pcs") + tmp_file = tempfile.NamedTemporaryFile(mode="w+", suffix=".pcs") # noqa: SIM115 if data is not None: tmp_file.write(data) tmp_file.flush() diff --git a/pcs/lib/validate.py b/pcs/lib/validate.py index 12babd226..647d6a555 100644 --- a/pcs/lib/validate.py +++ b/pcs/lib/validate.py @@ -115,14 +115,16 @@ def values_to_pairs( """ option_dict_with_pairs = {} for key, value in option_dict.items(): - if not isinstance(value, ValuePair): - value = ValuePair(original=value, normalized=normalize(key, value)) - option_dict_with_pairs[key] = value + option_dict_with_pairs[key] = ( + ValuePair(original=value, normalized=normalize(key, value)) + if not isinstance(value, ValuePair) + else value + ) return option_dict_with_pairs def pairs_to_values( - option_dict: Mapping[TypeOptionName, Union[TypeOptionValue, ValuePair]] + option_dict: Mapping[TypeOptionName, Union[TypeOptionValue, ValuePair]], ) -> TypeOptionRawMap: """ Take a dict which has OptionValuePairs as its values and return dict with @@ -133,16 +135,17 @@ def pairs_to_values( """ raw_option_dict = {} for key, value in option_dict.items(): + new_value = value if isinstance(value, ValuePair): - value = value.normalized - raw_option_dict[key] = str(value) + new_value = value.normalized + raw_option_dict[key] = str(new_value) return raw_option_dict def option_value_normalization( normalization_map: Mapping[ TypeOptionName, Callable[[TypeOptionValue], TypeOptionValue] - ] + ], ) -> TypeNormalizeFunc: """ Return function that takes key and value and return the normalized form. @@ -894,7 +897,7 @@ def _get_allowed_values(self) -> Any: return "an integer or integer-integer" return ( f"{self._at_least}..{self._at_most} or " - f"{self._at_least}..{self._at_most-1}-{self._at_least+1}..{self._at_most}" + f"{self._at_least}..{self._at_most - 1}-{self._at_least + 1}..{self._at_most}" ) @@ -1154,7 +1157,7 @@ def matches_regexp(value: TypeOptionValue, regexp: Union[str, Pattern]) -> bool: class _ValidateAddRemoveBase: # pylint: disable=too-many-instance-attributes - def __init__( + def __init__( # noqa: PLR0913 self, add_item_list: StringCollection, remove_item_list: StringCollection, @@ -1162,6 +1165,7 @@ def __init__( container_type: reports.types.AddRemoveContainerType, item_type: reports.types.AddRemoveItemType, container_id: str, + *, adjacent_item_id: Optional[str] = None, container_can_be_empty: bool = False, severity: Optional[ReportItemSeverity] = None, diff --git a/pcs/lib/xml_tools.py b/pcs/lib/xml_tools.py index 68ed48297..1506f3bd7 100644 --- a/pcs/lib/xml_tools.py +++ b/pcs/lib/xml_tools.py @@ -222,7 +222,7 @@ def reset_element( keep_attrs = keep_attrs or [] for child in list(element): element.remove(child) - for key in element.attrib.keys(): + for key in element.attrib: if key not in keep_attrs: del element.attrib[key] diff --git a/pcs/node.py b/pcs/node.py index cdf0fe98f..83b509fd6 100644 --- a/pcs/node.py +++ b/pcs/node.py @@ -1,8 +1,5 @@ import json -from typing import ( - Any, - Optional, -) +from typing import Any import pcs.lib.pacemaker.live as lib_pacemaker from pcs import utils @@ -12,10 +9,10 @@ CmdLineInputError, ) from pcs.cli.common.parse_args import ( + OUTPUT_FORMAT_OPTION, Argv, InputModifiers, KeyValueParser, - ModifierValueType, ) @@ -23,20 +20,19 @@ def node_attribute_cmd(lib: Any, argv: Argv, modifiers: InputModifiers) -> None: """ Options: * -f - CIB file (in lib wrapper) - * --force - allows not unique recipient values - * --name - specify attribute name to filter out + * --force - no error if attribute to delete doesn't exist + * --name - specify attribute name for filter + * --output-format - supported formats: text, cmd, json """ del lib - modifiers.ensure_only_supported("-f", "--force", "--name") - if modifiers.get("--name") and len(argv) > 1: + modifiers.ensure_only_supported( + "-f", "--force", "--name", output_format_supported=True + ) + if len(argv) < 2 or modifiers.is_specified_any( + ["--name", OUTPUT_FORMAT_OPTION] + ): raise CmdLineInputError() - if not argv: - attribute_show_cmd(filter_attr=modifiers.get("--name")) - elif len(argv) == 1: - attribute_show_cmd(argv.pop(0), filter_attr=modifiers.get("--name")) - else: - # --force is used only when setting attributes - attribute_set_cmd(argv.pop(0), argv) + attribute_set_cmd(argv.pop(0), argv) def node_utilization_cmd( @@ -45,10 +41,15 @@ def node_utilization_cmd( """ Options: * -f - CIB file (in lib wrapper) - * --name - specify attribute name to filter out + * --name - specify attribute name for filter + * --output-format - supported formats: text, cmd, json """ - modifiers.ensure_only_supported("-f", "--name") - if modifiers.get("--name") and len(argv) > 1: + modifiers.ensure_only_supported( + "-f", "--name", output_format_supported=True + ) + if len(argv) < 2 or modifiers.is_specified_any( + ["--name", OUTPUT_FORMAT_OPTION] + ): raise CmdLineInputError() utils.print_warning_if_utilization_attrs_has_no_effect( PropertyConfigurationFacade.from_properties_dtos( @@ -56,12 +57,7 @@ def node_utilization_cmd( lib.cluster_property.get_properties_metadata(), ) ) - if not argv: - print_node_utilization(filter_name=modifiers.get("--name")) - elif len(argv) == 1: - print_node_utilization(argv.pop(0), filter_name=modifiers.get("--name")) - else: - set_node_utilization(argv.pop(0), argv) + set_node_utilization(argv.pop(0), argv) def node_maintenance_cmd( @@ -154,49 +150,6 @@ def set_node_utilization(node: str, argv: Argv) -> None: utils.replace_cib_configuration(cib) -def print_node_utilization( - filter_node: Optional[str] = None, - filter_name: ModifierValueType = None, -) -> None: - """ - Commandline options: - * -f - CIB file - """ - cib = utils.get_cib_dom() - - node_element_list = cib.getElementsByTagName("node") - - if ( - filter_node - and filter_node - not in [ - node_element.getAttribute("uname") - for node_element in node_element_list - ] - and ( - utils.usefile - or filter_node - not in [ - node_attrs.name - for node_attrs in utils.getNodeAttributesFromPacemaker() - ] - ) - ): - utils.err(f"Unable to find a node: {filter_node}") - - utilization = {} - for node_el in node_element_list: - node = node_el.getAttribute("uname") - if filter_node is not None and node != filter_node: - continue - util_str = utils.get_utilization_str(node_el, filter_name) - if util_str: - utilization[node] = util_str - print("Node Utilization:") - for node in sorted(utilization): - print(f" {node}: {utilization[node]}") - - def node_pacemaker_status( lib: Any, argv: Argv, modifiers: InputModifiers ) -> None: @@ -209,21 +162,6 @@ def node_pacemaker_status( print(json.dumps(lib_pacemaker.get_local_node_status(utils.cmd_runner()))) -def attribute_show_cmd( - filter_node: Optional[str] = None, - filter_attr: ModifierValueType = None, -) -> None: - """ - Commandline options: - * -f - CIB file (in lib wrapper) - """ - node_attributes = utils.get_node_attributes( - filter_node=filter_node, filter_attr=filter_attr - ) - print("Node Attributes:") - attribute_print(node_attributes) - - def attribute_set_cmd(node: str, argv: Argv) -> None: """ Commandline options: @@ -232,14 +170,3 @@ def attribute_set_cmd(node: str, argv: Argv) -> None: """ for name, value in KeyValueParser(argv).get_unique().items(): utils.set_node_attribute(name, value, node) - - -def attribute_print(node_attributes): - """ - Commandline options: no options - """ - for node in sorted(node_attributes.keys()): - line_parts = [" " + node + ":"] - for name, value in sorted(node_attributes[node].items()): - line_parts.append(f"{name}={value}") - print(" ".join(line_parts)) diff --git a/pcs/pcs.8.in b/pcs/pcs.8.in index 63c40b786..3b644d054 100644 --- a/pcs/pcs.8.in +++ b/pcs/pcs.8.in @@ -120,11 +120,11 @@ Example: Create a new resource called 'VirtualIP' with IP address 192.168.0.99, .br pcs resource create VirtualIP ocf:heartbeat:IPaddr2 ip=192.168.0.99 cidr_netmask=32 nic=eth2 op monitor interval=30s .TP -delete -Deletes the resource, group, bundle or clone (and all resources within the group/bundle/clone). +delete ... +Deletes the specified resources, groups, bundles or clones (and all resources within the groups/bundles/clones). .TP -remove -Deletes the resource, group, bundle or clone (and all resources within the group/bundle/clone). +remove ... +Deletes the specified resources, groups, bundles or clones (and all resources within the groups/bundles/clones). .TP enable ... [\fB\-\-wait\fR[=n]] Allow the cluster to start the resources. Depending on the rest of the configuration (constraints, options, failures, etc), the resources may remain stopped. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the resources to start and then return 0 if the resources are started, or 1 if the resources have not yet started. If 'n' is not specified it defaults to 60 minutes. @@ -248,7 +248,7 @@ Remove specified operation (note: you must specify the exact operation propertie op remove Remove the specified operation id. .TP -op defaults [config] [\fB\-\-all\fR | \fB\-\-no\-check\-expired\fR] [\fB\-\-full\fR] [@OUTPUT_FORMAT_SYNTAX_DOC@] +op defaults [config] [\fB\-\-all\fR | \fB\-\-no\-expire\-check\fR] [\fB\-\-full\fR] [@OUTPUT_FORMAT_SYNTAX_DOC@] List currently configured default values for operations. If \fB\-\-all\fR is specified, also list expired sets of values. If \fB\-\-full\fR is specified, also list ids. If \fB\-\-no\-expire\-check\fR is specified, do not evaluate whether sets of values are expired. @OUTPUT_FORMAT_DESC_DOC@ .TP op defaults =... @@ -283,7 +283,7 @@ Expression looks like one of the following: .br () -You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. +You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. To specify a full name of an agent which does not have a provider, use a double colon, for example 'resource stonith::fence_virt'. Dates are expected to conform to ISO 8601 format. @@ -309,8 +309,8 @@ Add, remove or change default values for operations. This is a simplified comman NOTE: Defaults do not apply to resources / stonith devices which override them with their own defined values. .TP -meta [\fB\-\-wait\fR[=n]] -Add specified options to the specified resource, group or clone. Meta options should be in the format of name=value, options may be removed by setting an option without a value. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the changes to take effect and then return 0 if the changes have been processed or 1 otherwise. If 'n' is not specified it defaults to 60 minutes. +meta [\fB\-\-wait\fR[=n]] +Add specified options to the specified resource. Meta options should be in the format of name=value, options may be removed by setting an option without a value. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the changes to take effect and then return 0 if the changes have been processed or 1 otherwise. If 'n' is not specified it defaults to 60 minutes. .br Example: pcs resource meta TestResource failure\-timeout=50 resource\-stickiness= .TP @@ -361,7 +361,7 @@ Set resources listed to managed mode (default). If \fB\-\-monitor\fR is specifie unmanage ... [\fB\-\-monitor\fR] Set resources listed to unmanaged mode. When a resource is in unmanaged mode, the cluster is not allowed to start nor stop the resource. If \fB\-\-monitor\fR is specified, disable all monitor operations of the resources. .TP -defaults [config] [\fB\-\-all\fR | \fB\-\-no\-check\-expired\fR] [\fB\-\-full\fR] [@OUTPUT_FORMAT_SYNTAX_DOC@] +defaults [config] [\fB\-\-all\fR | \fB\-\-no\-expire\-check\fR] [\fB\-\-full\fR] [@OUTPUT_FORMAT_SYNTAX_DOC@] List currently configured default values for resources / stonith devices. If \fB\-\-all\fR is specified, also list expired sets of values. If \fB\-\-full\fR is specified, also list ids. If \fB\-\-no\-expire\-check\fR is specified, do not evaluate whether sets of values are expired. @OUTPUT_FORMAT_DESC_DOC@ .TP defaults =... @@ -390,7 +390,7 @@ Expression looks like one of the following: .br () -You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. +You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. To specify a full name of an agent which does not have a provider, use a double colon, for example 'resource stonith::fence_virt'. Dates are expected to conform to ISO 8601 format. @@ -579,6 +579,19 @@ Authenticate pcs/pcsd to pcsd on nodes configured in the local cluster. status View current cluster status (an alias of 'pcs status cluster'). .TP +rename +Rename configured cluster. The cluster has to be stopped to complete this operation. + +Manual steps are needed in case the cluster uses GFS2 filesystem or DLM: +.br +for GFS2: +.br + The lock table name on each GFS2 filesystem must be updated to reflect the new name of the cluster so that the filesystems can be mounted. +.br +for DLM: +.br + The DLM cluster name in the shared volume groups metadata must be updated so that the volume groups can start. +.TP sync Sync cluster configuration (files which are supported by all subcommands of this command) to all cluster nodes. .TP @@ -649,6 +662,60 @@ Shutdown specified guest node and remove it from the cluster. The node\-identifi node clear Remove specified node from various cluster caches. Use this if a removed node is still considered by the cluster to be a member of the cluster. .TP +node rename\-cib +Rename a cluster node in the CIB. This replaces all references to the old name with the new name. References which cannot be updated automatically are reported for manual review. + +This command is one step of the node rename procedure. The recommended procedure is: +.RS +.PD 0 +.IP \(bu 2 +put all nodes in standby mode, +.IP \(bu 2 +stop the cluster, +.IP \(bu 2 +authenticate nodes using their new names, +.IP \(bu 2 +rename nodes in corosync.conf using 'pcs cluster node rename\-corosync' command, +.IP \(bu 2 +at this step, you can change nodes' addresses, if you wish to do so, +.IP \(bu 2 +start the cluster, +.IP \(bu 2 +rename nodes in the CIB using this command, +.IP \(bu 2 +take all nodes from standby mode, +.IP \(bu 2 +optionally, you can deauthenticate old node names. +.PD +.RE +.TP +node rename\-corosync [\fB\-\-skip\-offline\fR] +Rename a cluster node in corosync.conf and distribute the updated configuration to all nodes. + +This command is one step of the node rename procedure. The recommended procedure is: +.RS +.PD 0 +.IP \(bu 2 +put all nodes in standby mode, +.IP \(bu 2 +stop the cluster, +.IP \(bu 2 +authenticate nodes using their new names, +.IP \(bu 2 +rename nodes in corosync.conf using this command, +.IP \(bu 2 +at this step, you can change nodes' addresses, if you wish to do so, +.IP \(bu 2 +start the cluster, +.IP \(bu 2 +rename nodes in the CIB using 'pcs cluster node rename\-cib' command, +.IP \(bu 2 +take all nodes from standby mode, +.IP \(bu 2 +optionally, you can deauthenticate old node names. +.PD +.RE +.TP link add =... [options ] Add a corosync link. One address must be specified for each cluster node. If no linknumber is specified, pcs will use the lowest available linknumber. .br @@ -704,6 +771,8 @@ Show status of all currently configured stonith devices. If \fB\-\-hide\-inactiv .TP config [@OUTPUT_FORMAT_SYNTAX_DOC@] []... Show options of all currently configured stonith devices or if stonith device ids are specified show the options for the specified stonith device ids. @OUTPUT_FORMAT_DESC_DOC@ + +Note: The 'json' output format does not include fencing levels. Use 'pcs stonith level config \-\-output\-format=json' to get fencing levels. .TP list [filter] [\fB\-\-nodesc\fR] Show list of all available stonith agents (if filter is provided then only stonith agents matching the filter will be shown). If \fB\-\-nodesc\fR is used then descriptions of stonith agents are not printed. @@ -734,11 +803,11 @@ If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the changes update\-scsi\-devices (set [...]) | (add [...] delete|remove [...] ) Update scsi fencing devices without affecting other resources. You must specify either list of set devices or at least one device for add or delete/remove devices. Stonith resource must be running on one cluster node. Each device will be unfenced on each cluster node running cluster. Supported fence agents: fence_scsi, fence_mpath. .TP -delete -Remove stonith id from configuration. +delete ... +Remove stonith resources from configuration. .TP -remove -Remove stonith id from configuration. +remove ... +Remove stonith resources from configuration. .TP op add [operation properties] Add operation for specified stonith device. @@ -755,7 +824,7 @@ Remove specified operation (note: you must specify the exact operation propertie op remove Remove the specified operation id. .TP -op defaults [config] [\fB\-\-all\fR | \fB\-\-no\-check\-expired\fR] [\fB\-\-full\fR] [@OUTPUT_FORMAT_SYNTAX_DOC@] +op defaults [config] [\fB\-\-all\fR | \fB\-\-no\-expire\-check\fR] [\fB\-\-full\fR] [@OUTPUT_FORMAT_SYNTAX_DOC@] This command is an alias of 'resource op defaults [config]' command. List currently configured default values for operations. If \fB\-\-all\fR is specified, also list expired sets of values. If \fB\-\-full\fR is specified, also list ids. If \fB\-\-no\-expire\-check\fR is specified, do not evaluate whether sets of values are expired. @OUTPUT_FORMAT_DESC_DOC@ @@ -796,7 +865,7 @@ Expression looks like one of the following: .br () -You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. +You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. To specify a full name of an agent which does not have a provider, use a double colon, for example 'resource stonith::fence_virt'. Dates are expected to conform to ISO 8601 format. @@ -835,7 +904,7 @@ Add specified options to the specified stonith device. Meta options should be in Example: pcs stonith meta test_stonith failure\-timeout=50 resource\-stickiness= .TP -defaults [config] [\fB\-\-all\fR | \fB\-\-no\-check\-expired\fR] [\fB\-\-full\fR] [@OUTPUT_FORMAT_SYNTAX_DOC@] +defaults [config] [\fB\-\-all\fR | \fB\-\-no\-expire\-check\fR] [\fB\-\-full\fR] [@OUTPUT_FORMAT_SYNTAX_DOC@] This command is an alias of 'resource defaults [config]' command. List currently configured default values for resources / stonith devices. If \fB\-\-all\fR is specified, also list expired sets of values. If \fB\-\-full\fR is specified, also list ids. If \fB\-\-no\-expire\-check\fR is specified, do not evaluate whether sets of values are expired. @OUTPUT_FORMAT_DESC_DOC@ @@ -870,7 +939,7 @@ Expression looks like one of the following: .br () -You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. +You may specify all or any of 'standard', 'provider' and 'type' in a resource expression. For example: 'resource ocf::' matches all resources of 'ocf' standard, while 'resource ::Dummy' matches all resources of 'Dummy' type regardless of their standard and provider. To specify a full name of an agent which does not have a provider, use a double colon, for example 'resource stonith::fence_virt'. Dates are expected to conform to ISO 8601 format. @@ -937,11 +1006,11 @@ Allow the cluster to use the stonith devices. If \fB\-\-wait\fR is specified, pc disable ... [\fB\-\-wait[=n]\fR] Attempt to stop the stonith devices if they are running and disallow the cluster to use them. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the stonith devices to stop and then return 0 if the stonith devices are stopped or 1 if the stonith devices have not stopped. If 'n' is not specified it defaults to 60 minutes. .TP -level [config] -Lists all of the fencing levels currently configured. +level [config] [@OUTPUT_FORMAT_SYNTAX_DOC@] +Lists all of the fencing levels currently configured. @OUTPUT_FORMAT_DESC_DOC@ .TP -level add [stonith id]... -Add the fencing level for the specified target with the list of stonith devices to attempt for that target at that level. Fence levels are attempted in numerical order (starting with 1). If a level succeeds (meaning all devices are successfully fenced in that level) then no other levels are tried, and the target is considered fenced. Target may be a node name or % or node%, a node name regular expression regexp% or a node attribute value attrib%=. +level add [...] [id=] +Add the fencing level for the specified target with the list of stonith devices to attempt for that target at that level. Fence levels are attempted in numerical order (starting with 1). If a level succeeds (meaning all devices are successfully fenced in that level) then no other levels are tried, and the target is considered fenced. Target may be a node name or % or node%, a node name regular expression regexp% or a node attribute value attrib%=. Id for the fencing level will be generated if not specified by the id option. .TP level delete [target ] [stonith ...] Removes the fence level for the level, target and/or devices specified. If no target or devices are specified then the fence level is removed. Target may be a node name or % or node%, a node name regular expression regexp% or a node attribute value attrib%=. @@ -1298,13 +1367,22 @@ destroy Remove booth configuration files. .TP ticket add [= ...] -Add new ticket to the current configuration. Ticket options are specified in booth manpage. +Add new ticket to the local site configuration. Ticket options are specified in booth manpage. .TP ticket delete -Remove the specified ticket from the current configuration. +Remove the specified ticket from the local site configuration. The ticket remains loaded in the current CIB and should be cleaned up using the "pcs ticket cleanup" command. .TP ticket remove -Remove the specified ticket from the current configuration. +Remove the specified ticket from the local site configuration. The ticket remains loaded in the current CIB and should be cleaned up using the "pcs ticket cleanup" command. +.TP +ticket cleanup +Remove specified ticket from CIB at the local site. +.TP +ticket standby +Tell the cluster on the local site that this ticket is standby. The dependent resources will be stopped or demoted gracefully without triggering loss\-policies. +.TP +ticket unstandby +Tell the cluster on the local site that this ticket is no longer standby. .TP config [] Show booth configuration from the specified node or from the current node if node not specified. @@ -1334,6 +1412,7 @@ Print current status of booth on the local node. .TP pull Pull booth configuration from the specified node. +Pull booth configuration from the specified node. After pulling the configuration, the booth should be restarted. In case any tickets were removed, the "pcs ticket cleanup" command should be used to remove any leftover tickets still loaded in the CIB at the current site. .TP sync [\fB\-\-skip\-offline\fR] Send booth configuration from the local node to all nodes in the cluster. @@ -1382,7 +1461,7 @@ xml View xml version of status (output from crm_mon \fB\-r\fR \fB\-1\fR \fB\-X\fR). .TP wait [] -Wait for the cluster to settle into stable state. Timeout can be specified as bare number which describes number of seconds or number with unit (s or sec for seconds, m or min for minutes, h or hr for hours). If 'timeout' is not specified it defaults to 60 minutes. +Wait for the cluster to settle into stable state. Timeout can be specified as bare number which describes number of seconds or number with unit (s or sec for seconds, m or min for minutes, h or hr for hours). If 'timeout' is not specified or set to zero, it defaults to 60 minutes. .br Example: pcs status wait 30min .TP @@ -1520,8 +1599,9 @@ deauth []... Delete authentication tokens which allow pcs/pcsd on the current system to connect to remote pcsd instances on specified host names. If the current system is a member of a cluster, the tokens will be deleted from all nodes in the cluster. If no host names are specified all tokens will be deleted. After this command is run this node will need to re-authenticate against other nodes to be able to connect to them. .SS "node" .TP -attribute [[] [\fB\-\-name\fR ] | = ...] -Manage node attributes. If no parameters are specified, show attributes of all nodes. If one parameter is specified, show attributes of specified node. If \fB\-\-name\fR is specified, show specified attribute's value from all nodes. If more parameters are specified, set attributes of specified node. Attributes can be removed by setting an attribute without a value. +attribute [[] [\fB\-\-name\fR ] | (@OUTPUT_FORMAT_SYNTAX_DOC@) | = ...] +Manage node attributes. If no parameters are specified, show attributes of all nodes. If one parameter is specified, show attributes of specified node. If \fB\-\-name\fR is specified, show specified attribute's value from all nodes. If more parameters are specified, set attributes of specified node. Attributes can be removed by setting an attribute without a value. @OUTPUT_FORMAT_DESC_DOC@ + .TP maintenance [\fB\-\-all\fR | ...] [\fB\-\-wait\fR[=n]] Put specified node(s) into maintenance mode, if no nodes or options are specified the current node will be put into maintenance mode, if \fB\-\-all\fR is specified all nodes will be put into maintenance mode. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the node(s) to be put into maintenance mode and then return 0 on success or 1 if the operation not succeeded yet. If 'n' is not specified it defaults to 60 minutes. @@ -1535,12 +1615,14 @@ Put specified node(s) into standby mode (the node specified will no longer be ab unstandby [\fB\-\-all\fR | ...] [\fB\-\-wait\fR[=n]] Remove node(s) from standby mode (the node specified will now be able to host resources), if no nodes or options are specified the current node will be removed from standby mode, if \fB\-\-all\fR is specified all nodes will be removed from standby mode. If \fB\-\-wait\fR is specified, pcs will wait up to 'n' seconds for the node(s) to be removed from standby mode and then return 0 on success or 1 if the operation not succeeded yet. If 'n' is not specified it defaults to 60 minutes. .TP -utilization [[] [\fB\-\-name\fR ] | = ...] -Add specified utilization options to specified node. If node is not specified, shows utilization of all nodes. If \fB\-\-name\fR is specified, shows specified utilization value from all nodes. If utilization options are not specified, shows utilization of specified node. Utilization option should be in format name=value, value has to be integer. Options may be removed by setting an option without a value. Example: pcs node utilization node1 cpu=4 ram= For the utilization configuration to be in effect, cluster property 'placement-strategy' must be configured accordingly. +utilization [[] [\fB\-\-name\fR ] | (@OUTPUT_FORMAT_SYNTAX_DOC@) | = ...] +Add specified utilization options to specified node. If node is not specified, shows utilization of all nodes. If \fB\-\-name\fR is specified, shows specified utilization value from all nodes. If utilization options are not specified, shows utilization of specified node. Utilization option should be in format name=value, value has to be integer. Options may be removed by setting an option without a value. @OUTPUT_FORMAT_DESC_DOC@ + +Example: pcs node utilization node1 cpu=4 ram= For the utilization configuration to be in effect, cluster property 'placement-strategy' must be configured accordingly. .SS "alert" .TP -[config] -Show all configured alerts. +[config] [@OUTPUT_FORMAT_SYNTAX_DOC@] +Show all configured alerts. @OUTPUT_FORMAT_DESC_DOC@ .TP create path= [id=] [description=] [options [ + + + Remove specified booth ticket from CIB. + + pcs commands: booth ticket cleanup + API v2: booth.ticket_cleanup + + + + + Change the state of the booth ticket to standby and back to active. + + pcs commands: booth ticket ( standby | unstandby ) + API v2: + booth.ticket_standby + booth.ticket_unstandby + + @@ -218,6 +236,30 @@ daemon urls: cluster_destroy (parameter all=1) + + + Rename a cluster node in the CIB. + + pcs commands: cluster node rename-cib + API v2: cluster.rename_node_cib + + + + + Rename a cluster node in corosync.conf. + + pcs commands: cluster node rename-corosync + API v2: cluster.rename_node_corosync + + + + + Rename configured cluster. The cluster has to be stopped + + pcs commands: cluster rename + API v2: cluster.rename + + Create a tarball containing everything needed when reporting cluster @@ -249,11 +291,12 @@ daemon urls: get_corosync_conf - + Provide the local corosync.conf in a structured format. pcs commands: cluster config show + API v2: cluster.get_corosync_conf_struct @@ -621,6 +664,11 @@ + + + API v2: node.get_config_dto + + Show node attributes, add and remove a node attribute. @@ -629,6 +677,13 @@ daemon urls: add_node_attr_remote + + + Show / export node attributes in various formats. + + pcs commands: node attribute --output-format=text|json|cmd + + Set list of node attributes for a node. @@ -726,7 +781,13 @@ pcs commands: node utilization + + + Show / export node utilization in various formats. + pcs commands: node utilization --output-format=text|json|cmd + + @@ -846,6 +907,18 @@ /api/v1/alert-update-recipient/v1 + + + Show / export alerts in various formats. + + pcs commands: alert [config] --output-format=text|json|cmd + + + + + API v2: alert.get_config_dto + + @@ -903,6 +976,13 @@ API v2: cib.remove_elements + + + Remove resources by ids. + + API v2: cib.remove_elements + + Remove cib elements by ids. @@ -1191,6 +1271,13 @@ pcs commands: property defaults + + + Remove cluster-name property + + daemon urls: /api/v1/cluster-property-remove-name/v1 + + Show and set resource operations defaults, can set multiple defaults at @@ -1238,7 +1325,7 @@ denoted with "(expired)". Use --no-expire-check to disable evaluating whether rules are expired. - pcs commands: resource op defaults [config] --all | --no-check-expired + pcs commands: resource op defaults [config] --all | --no-expire-check @@ -1301,7 +1388,7 @@ denoted with "(expired)". Use --no-expire-check to disable evaluating whether rules are expired. - pcs commands: resource defaults [config] --all | --no-check-expired + pcs commands: resource defaults [config] --all | --no-expire-check @@ -1450,13 +1537,16 @@ pcs commands: resource ( delete | remove ) daemon urls: remove_resource + API v2: cib.remove_elements - + Delete several resources at once. + pcs commands: resource ( delete | remove ) daemon urls: remove_resource + API v2: cib.remove_elements @@ -1507,6 +1597,22 @@ daemon urls: add_meta_attr_remote + + + The resource meta command also supports bundle resources. + + pcs commands: resource meta + daemon urls: add_meta_attr_remote + + + + + The add_meta_attr_remote url also supports stonith when called with + an explicit is-stonith flag. + + daemon urls: add_meta_attr_remote + + Update several meta attributes of a resource at once. @@ -1521,6 +1627,13 @@ pcs commands: resource update --wait, resource meta --wait + + + Add, update or remove meta attributes of any resource. + + API v2: resource.update_meta + + Create and delete an operation of an existing resource. An operation can @@ -1960,6 +2073,13 @@ stonith config --output-format=text|json|cmd + + + Get configured pacemaker resources. + + API v2: resource.get_configured_resources + + Forget history of resources and redetect their current state. Optionally @@ -2003,12 +2123,13 @@ pcs commands: resource relocate ( dry-run | run | show | clear ) - + Restart a resource, allow specifying a node for multi-node resources (clones, bundles), allow waiting for the resource to restart. pcs commands: resource restart + API v2: resource.restart @@ -2090,13 +2211,16 @@ pcs commands: stonith ( delete | remove ) daemon urls: currently missing, remove_resource is used instead which works for now + API v2: cib.remove_elements - + Delete several stonith resources at once. + pcs commands: stonith ( delete | remove ) daemon urls: remove_resource + API v2: cib.remove_elements @@ -2274,6 +2398,24 @@ /api/v1/fencing-topology-add-level/v1 + + + Add a new stonith level with an ability to set its id. + + pcs commands: stonith level add + API v2: fencing_topology.add_level + + + + + Display configured stonith levels in various formats. + + pcs commands: + stonith level config --output-format=text|json|cmd + stonith config --output-format=text|cmd + API v2: fencing_topology.get_config_dto + + Support specifying a target by a node attribute. @@ -2332,6 +2474,17 @@ pcs commands: tag create, tag update + + + Display configured tags in various formats. + + pcs commands: + tag config --output-format=text|json|cmd + APIv2: + tag.get_config_dto + + + @@ -2608,6 +2761,15 @@ /api/v1/sbd-enable-sbd/v1 + + + Command for disabling SBD accepts '--force' option / force-flags + + pcs commands: stonith sbd disable + daemon urls: /api/v1/sbd-disable-sbd/v1 + API v2: sbd.disable_sbd + + Allows to set SBD_TIMEOUT_ACTION option. @@ -2661,6 +2823,13 @@ daemon urls: pacemaker_node_status + + + Display status of the remote site cluster. + + daemon urls: /api/v1/status-full-cluster-status-plaintext/v1 + + Query status of resources. diff --git a/pcsd/conf/pcsd b/pcsd/conf/pcsd index 0ffbd616e..7206e95ac 100644 --- a/pcsd/conf/pcsd +++ b/pcsd/conf/pcsd @@ -48,5 +48,11 @@ PCSD_SESSION_LIFETIME=3600 # is 50 (even if set lower). PCSD_RESTART_AFTER_REQUESTS=200 +# These environment variables set the maximum query string bytesize and the +# maximum number of query parameters that pcsd will attempt to parse. +# See CVE-2025-46727 for details. +#RACK_QUERY_PARSER_BYTESIZE_LIMIT=4194304 +#RACK_QUERY_PARSER_PARAMS_LIMIT=4096 + # Do not change RACK_ENV=production diff --git a/pcsd/corosyncconf.rb b/pcsd/corosyncconf.rb index bcb6df295..4f7a7154c 100644 --- a/pcsd/corosyncconf.rb +++ b/pcsd/corosyncconf.rb @@ -111,7 +111,7 @@ def parent=(parent) def CorosyncConf::parse_string(conf_text) - conf_text = conf_text.force_encoding("utf-8") + conf_text = String.new(conf_text, encoding: Encoding::UTF_8) root = Section.new('') self.parse_section(conf_text.split("\n"), root) return root diff --git a/pcsd/pcs.rb b/pcsd/pcs.rb index ffc0d3554..287560b30 100644 --- a/pcsd/pcs.rb +++ b/pcsd/pcs.rb @@ -63,15 +63,14 @@ def add_node_attr(auth_user, node, key, value) return retval end -def add_meta_attr(auth_user, resource, key, value) - cmd = ["resource", "meta", resource, key.to_s + "=" + value.to_s] +def add_meta_attr(auth_user, resource, key, value, is_stonith) + resource_or_stonith = if is_stonith then "stonith" else "resource" end + cmd = [resource_or_stonith, "meta", resource, key.to_s + "=" + value.to_s] flags = [] - if key.to_s == "remote-node" - # --force is a workaround for: - # 1) Error: this command is not sufficient for create guest node, use 'pcs - # cluster node add-guest', use --force to override - # 2) Error: this command is not sufficient for remove guest node, use 'pcs - # cluster node remove-guest', use --force to override + if ["remote-node", "remote-addr"].include?(key.to_s) + # --force is a workaround for missing guest node management in the web ui + # The reports generated are to prevent adding guest nodes, removing guest + # nodes and changing their connection parameters. flags << "--force" end stdout, stderr, retval = run_cmd(auth_user, PCS, *flags, "--", *cmd) @@ -1055,7 +1054,7 @@ def pcs_auth(auth_user, nodes) # Only tokens used in pcsd-to-pcsd communication can and need to be synced. # Those are accessible only when running under root account. if Process.uid != 0 - # Other tokens just need to be stored localy for a user. + # Other tokens just need to be stored locally for a user. sync_successful, sync_responses = Cfgsync::save_sync_new_known_hosts( new_hosts, [], [], nil ) @@ -1096,7 +1095,7 @@ def pcs_deauth(auth_user, host_names) # Only tokens used in pcsd-to-pcsd communication can and need to be synced. # Those are accessible only when running under root account. if Process.uid != 0 - # Other tokens just need to be stored localy for a user. + # Other tokens just need to be stored locally for a user. sync_successful, sync_responses = Cfgsync::save_sync_new_known_hosts( [], host_names, [], nil ) diff --git a/pcsd/pcsd-ruby.service.in b/pcsd/pcsd-ruby.service.in index 7e6a3c25b..09e474aaa 100644 --- a/pcsd/pcsd-ruby.service.in +++ b/pcsd/pcsd-ruby.service.in @@ -14,6 +14,10 @@ EnvironmentFile=@CONF_DIR@/pcsd @SYSTEMD_GEM_HOME@ # This file holds the selinux context ExecStart=@LIB_DIR@/pcsd/pcsd +StateDirectory=pcsd +StateDirectoryMode=0700 +LogsDirectory=pcsd +LogsDirectoryMode=0700 Type=notify [Install] diff --git a/pcsd/pcsd.rb b/pcsd/pcsd.rb index 5b8d0a11b..a86b5d95f 100644 --- a/pcsd/pcsd.rb +++ b/pcsd/pcsd.rb @@ -50,7 +50,14 @@ def getAuthUser() before do @auth_user = getAuthUser() - $cluster_name, $cluster_uuid = get_cluster_name_and_uuid() + begin + $cluster_name, $cluster_uuid = get_cluster_name_and_uuid() + rescue CorosyncConf::ParseErrorException => e + $logger.error("Unable to parse corosync.conf: #{e.message}") + $logger.warn("Continuing request processing as if this node is not in a cluster") + $cluster_name = '' + $cluster_uuid = '' + end if PCSD_RESTART_AFTER_REQUESTS > 0 $request_counter += 1 # Even though Puma is multi-threaded, we don't need to lock here since GVL @@ -74,6 +81,13 @@ def getAuthUser() CAPABILITIES_PCSD = capabilities_pcsd.freeze end +if Rack.const_defined?(:QueryParser) and Rack::QueryParser.const_defined?(:QueryLimitError) + error Rack::QueryParser::QueryLimitError do + $logger.warn(env['sinatra.error'].message) + return 400, env['sinatra.error'].message + end +end + def run_cfgsync node_connected = true if Cfgsync::ConfigSyncControl.sync_thread_allowed?() diff --git a/pcsd/pcsd.service.in b/pcsd/pcsd.service.in index dca5052d4..c1eadb3e9 100644 --- a/pcsd/pcsd.service.in +++ b/pcsd/pcsd.service.in @@ -10,6 +10,10 @@ After=pcsd-ruby.service [Service] EnvironmentFile=@CONF_DIR@/pcsd ExecStart=@SBINDIR@/pcsd +StateDirectory=pcsd +StateDirectoryMode=0700 +LogsDirectory=pcsd +LogsDirectoryMode=0700 Type=notify KillMode=mixed diff --git a/pcsd/remote.rb b/pcsd/remote.rb index 932123213..047a9de68 100644 --- a/pcsd/remote.rb +++ b/pcsd/remote.rb @@ -1008,80 +1008,31 @@ def remove_resource(params, request, auth_user) end force = params['force'] user = PCSAuth.getSuperuserAuth() - no_error_if_not_exists = params.include?('no_error_if_not_exists') resource_list = [] - errors = '' - resource_to_remove = [] params.each { |param,_| if param.start_with?('resid-') resource_list << param.split('resid-', 2)[1] end } - tmp_file = nil - if force - resource_to_remove = resource_list + + resource_or_stonith = if params["is-stonith"] == "true" then + "stonith" else - begin - tmp_file = Tempfile.new('temp_cib') - _, err, retval = run_cmd(user, PCS, '--', 'cluster', 'cib', tmp_file.path) - if retval != 0 - return [400, 'Unable to stop resource(s).'] - end - cmd = [PCS, '-f', tmp_file.path, '--', 'resource', 'disable'] - resource_list.each { |resource| - out, err, retval = run_cmd(user, *(cmd + [resource])) - if retval != 0 - unless ( - (out + err).join('').include?(' does not exist') and - no_error_if_not_exists - ) - errors += "Unable to stop resource '#{resource}': #{err.join('')}" - end - else - resource_to_remove << resource - end - } - _, _, retval = run_cmd( - user, PCS, '--config', '--wait', '--', 'cluster', 'cib-push', tmp_file.path - ) - if retval != 0 - return [400, 'Unable to stop resource(s).'] - end - errors.strip! - unless errors.empty? - $logger.info("Stopping resource(s) errors:\n#{errors}") - return [400, errors] - end - rescue IOError - return [400, 'Unable to stop resource(s).'] - ensure - if tmp_file - tmp_file.close! - end - end + "resource" end - resource_to_remove.each { |resource| - cmd = ['resource', 'delete', resource] - flags = [] - if force - flags << '--force' - end - out, err, retval = run_cmd(auth_user, PCS, *flags, '--', *cmd) - if retval != 0 - unless ( - (out + err).join('').include?(' does not exist.') and - no_error_if_not_exists - ) - errors += err.join(' ').strip + "\n" - end - end - } - errors.strip! - if errors.empty? + + cmd = [resource_or_stonith, 'delete'] + flags = [] + if force + flags << '--force' + end + out, err, retval = run_cmd(auth_user, PCS, *flags, '--', *cmd, *resource_list) + + if retval == 0 return 200 else - $logger.info("Remove resource errors:\n"+errors) - return [400, errors] + $logger.info("Remove resource errors:\n"+err.join('\n')) + return [400, err] end end @@ -1209,7 +1160,11 @@ def add_meta_attr_remote(params, request, auth_user) return 403, 'Permission denied' end retval = add_meta_attr( - auth_user, params["res_id"], params["key"],params["value"] + auth_user, + params["res_id"], + params["key"], + params["value"], + params["is-stonith"] == "true" ) if retval == 0 return [200, "Successfully added meta attribute"] @@ -1404,6 +1359,9 @@ def update_cluster_settings(params, request, auth_user) end end + options = [] + options << "--force" if params["force"] + if to_update.empty? $logger.info('No properties to update') else @@ -1412,10 +1370,10 @@ def update_cluster_settings(params, request, auth_user) cmd_args << "#{prop.downcase}=#{properties[prop]}" } stdout, stderr, retval = run_cmd( - auth_user, PCS, '--', 'property', 'set', *cmd_args + auth_user, PCS, *options, '--', 'property', 'set', *cmd_args ) if retval != 0 - return [400, stderr.join('').gsub(', (use --force to override)', '')] + return [400, stderr.join('')] end end return [200, "Update Successful"] diff --git a/pcsd/settings.rb.in b/pcsd/settings.rb.in index a12600048..3c3e9f8f4 100644 --- a/pcsd/settings.rb.in +++ b/pcsd/settings.rb.in @@ -2,7 +2,7 @@ PCS_VERSION = '@VERSION@' PCS_EXEC = '@SBINDIR@/pcs' PCS_INTERNAL_EXEC = '@LIB_DIR@/pcs/pcs_internal' PCSD_EXEC_LOCATION = '@LIB_DIR@/pcsd' -PCSD_VAR_LOCATION = '@LOCALSTATEDIR@/lib/pcsd' +PCSD_VAR_LOCATION = ENV['STATE_DIRECTORY'] || '@LOCALSTATEDIR@/lib/pcsd' PCSD_DEFAULT_PORT = 2224 PCSD_RUBY_SOCKET = '@LOCALSTATEDIR@/run/pcsd-ruby.socket' PCSD_RESTART_AFTER_REQUESTS = 200 diff --git a/pcsd/test/test_cfgsync.rb b/pcsd/test/test_cfgsync.rb index 143e170c1..2a00b0654 100644 --- a/pcsd/test/test_cfgsync.rb +++ b/pcsd/test/test_cfgsync.rb @@ -153,9 +153,22 @@ def test_basics() assert_equal(3, cfg.version) assert_equal('b35f951a228ac0734d4c1e45fe73c03b18bca380', cfg.hash) + # rubygem-json shipped with ruby 3.4 changed the way JSON.pretty_generate + # works a bit. This results in different strings produced by the gem in + # ruby older that 3.4 compared to ruby 3.4+. By examining the output of + # JSON.pretty_generate, we can figure out which version of the gem we run + # against and use the correct hash for the produced string. + # https://bugzilla.redhat.com/show_bug.cgi?id=2331005 + old_rubygem = JSON.pretty_generate([]) == "[\n\n]" + if old_rubygem + expected_hash = '26579b79a27f9f56e1acd398eb761d2eb1872c6d' + else + expected_hash = 'db2b44331c63c25874ec30398fa82decd740fef4' + end + cfg.version = 4 assert_equal(4, cfg.version) - assert_equal('26579b79a27f9f56e1acd398eb761d2eb1872c6d', cfg.hash) + assert_equal(expected_hash, cfg.hash) cfg.text = '{ "format_version": 2, @@ -1174,4 +1187,3 @@ def test_ok_and_errors() ) end end - diff --git a/pcsd/test/test_config.rb b/pcsd/test/test_config.rb index a580b24fa..f29785f66 100644 --- a/pcsd/test/test_config.rb +++ b/pcsd/test/test_config.rb @@ -5,6 +5,14 @@ require 'config.rb' require 'permissions.rb' +def assert_equal_json(json1, json2) + # https://bugzilla.redhat.com/show_bug.cgi?id=2331005 + assert_equal( + JSON.pretty_generate(JSON.parse(json1)), + JSON.pretty_generate(JSON.parse(json2)) + ) +end + class TestConfig < Test::Unit::TestCase def setup $logger = MockLogger.new @@ -56,7 +64,7 @@ def test_parse_nil() cfg = PCSConfig.new(text) assert_equal(0, cfg.clusters.length) assert_equal([], $logger.log) - assert_equal(fixture_nil_config, cfg.text) + assert_equal_json(fixture_nil_config, cfg.text) end def test_parse_empty() @@ -64,7 +72,7 @@ def test_parse_empty() cfg = PCSConfig.new(text) assert_equal(0, cfg.clusters.length) assert_equal([], $logger.log) - assert_equal(fixture_empty_config, cfg.text) + assert_equal_json(fixture_empty_config, cfg.text) end def test_parse_whitespace() @@ -72,7 +80,7 @@ def test_parse_whitespace() cfg = PCSConfig.new(text) assert_equal(0, cfg.clusters.length) assert_equal([], $logger.log) - assert_equal(fixture_empty_config, cfg.text) + assert_equal_json(fixture_empty_config, cfg.text) end def test_parse_hash_empty() @@ -82,7 +90,7 @@ def test_parse_hash_empty() [['error', 'Unable to parse pcs_settings file: invalid file format']], $logger.log ) - assert_equal(fixture_empty_config, cfg.text) + assert_equal_json(fixture_empty_config, cfg.text) end def test_parse_hash_no_version() @@ -104,7 +112,7 @@ def test_parse_hash_no_version() [['error', 'Unable to parse pcs_settings file: invalid file format']], $logger.log ) - assert_equal(fixture_empty_config, cfg.text) + assert_equal_json(fixture_empty_config, cfg.text) end def test_parse_malformed() @@ -126,17 +134,17 @@ def test_parse_malformed() assert_equal('error', $logger.log[0][0]) assert_match( # the number is based on JSON gem version - /Unable to parse pcs_settings file: (\d+: )?unexpected token/, + /Unable to parse pcs_settings file: ((\d+: )?unexpected token|expected )/, $logger.log[0][1] ) - assert_equal(fixture_empty_config, cfg.text) + assert_equal_json(fixture_empty_config, cfg.text) end def test_parse_format1_empty() text = '[]' cfg = PCSConfig.new(text) assert_equal(0, cfg.clusters.length) - assert_equal( + assert_equal_json( '{ "format_version": 2, "data_version": 0, @@ -177,7 +185,7 @@ def test_parse_format1_one_cluster() assert_equal(1, cfg.clusters.length) assert_equal("cluster71", cfg.clusters[0].name) assert_equal(["rh71-node1", "rh71-node2"], cfg.clusters[0].nodes) - assert_equal( + assert_equal_json( '{ "format_version": 2, "data_version": 0, @@ -218,7 +226,7 @@ def test_parse_format2_empty() assert_equal(2, cfg.format_version) assert_equal(0, cfg.data_version) assert_equal(0, cfg.clusters.length) - assert_equal(fixture_empty_config, cfg.text) + assert_equal_json(fixture_empty_config, cfg.text) end def test_parse_format2_one_cluster() @@ -247,7 +255,7 @@ def test_parse_format2_one_cluster() assert_equal(1, cfg.clusters.length) assert_equal("cluster71", cfg.clusters[0].name) assert_equal(["rh71-node1", "rh71-node2"], cfg.clusters[0].nodes) - assert_equal(text, cfg.text) + assert_equal_json(text, cfg.text) end def test_parse_format2_two_clusters() @@ -318,7 +326,7 @@ def test_parse_format2_two_clusters() ] } }' - assert_equal(out_text, cfg.text) + assert_equal_json(out_text, cfg.text) end def test_parse_format2_bad_cluster() @@ -347,7 +355,7 @@ def test_parse_format2_bad_cluster() assert_equal(1, cfg.clusters.length) assert_equal("cluster71", cfg.clusters[0].name) assert_equal(["rh71-node1", "rh71-node2"], cfg.clusters[0].nodes) - assert_equal( + assert_equal_json( '{ "format_version": 2, "data_version": 9, @@ -475,7 +483,7 @@ def test_parse_format2_permissions() } }' cfg = PCSConfig.new(text) - assert_equal(out_text, cfg.text) + assert_equal_json(out_text, cfg.text) perms = cfg.permissions_local assert_equal(false, perms.allows?('user1', [], Permissions::FULL)) @@ -684,7 +692,7 @@ def assert_empty_data(cfg) assert_equal(1, cfg.format_version) assert_equal(0, cfg.data_version) assert_equal(0, cfg.known_hosts.length) - assert_equal(fixture_empty_config(), cfg.text) + assert_equal_json(fixture_empty_config(), cfg.text) end def assert_known_host(host, name, token, dest_list) @@ -723,7 +731,7 @@ def test_parse_malformed() assert_equal('error', $logger.log[0][0]) assert_match( # the number is based on JSON gem version - /Unable to parse known-hosts file: (\d+: )?unexpected token/, + /Unable to parse known-hosts file: ((\d+: )?unexpected token|expected )/, $logger.log[0][1] ) assert_empty_data(cfg) @@ -765,7 +773,7 @@ def test_parse_format1_simple() ], cfg.known_hosts['node1'].dest_list ) - assert_equal(text, cfg.text) + assert_equal_json(text, cfg.text) end def test_parse_format1_complex() @@ -825,7 +833,7 @@ def test_parse_format1_complex() {'addr' => '10.0.2.2', 'port' => 2235} ] ) - assert_equal(text, cfg.text) + assert_equal_json(text, cfg.text) end def test_parse_format1_error() @@ -888,7 +896,7 @@ def test_update() {'addr' => '10.0.1.3', 'port' => 2224} ] ) - assert_equal( + assert_equal_json( cfg.text, '{ "format_version": 1, diff --git a/plans.fmf b/plans.fmf new file mode 100644 index 000000000..23439ae1b --- /dev/null +++ b/plans.fmf @@ -0,0 +1,210 @@ +# https://tmt.readthedocs.io/en/stable/spec/plans.html +# https://tmt.readthedocs.io/en/stable/spec/plans.html#discover +discover: + how: fmf + +# https://tmt.readthedocs.io/en/stable/spec/plans.html#prepare +prepare: + # https://docs.testing-farm.io/Testing%20Farm/0.1/test-environment.html#_tag_repository + - name: Disable testing-farm RHEL repos that may contain packages of + different versions than we want + when: distro == rhel, centos-stream + how: shell + script: + - | + for repo in testing-farm-tag-repository \ + testing-farm-tag-repository-mainline \ + testing-farm-tag-repository-z-stream; do + if dnf repolist --enabled -v | grep "^Repo-id\s*:\s*${repo}$"; then + dnf config-manager --set-disabled "${repo}" + fi; + done + + - name: Enable testing-farm HighAvailability RHEL repo + when: distro == rhel, centos-stream + how: shell + script: + - | + for repo in rhel-HighAvailability \ + highavailability; do + if dnf repolist --disabled -v | + grep "^Repo-id\s*:\s*${repo}$"; then + dnf config-manager --set-enabled "${repo}" + fi; + done + + - name: Install common packages required for pcs + how: install + package: + - autoconf + - automake + - bash + - bzip2 + - coreutils + - curl + - diffutils + - findutils + - gawk + - git + - make + - nss-tools + - pkgconf-pkg-config + - psmisc + - sed + - systemd + - tar + - time + - wget + - xz + # minimum python packages for pcs ./configure --enable-tests-only + - python3 + - python3-pip + - python3-setuptools + - python3-wheel + # pcs python development requirements + - python3-cryptography + - python3-dateutil + - python3-devel + - python3-lxml + - python3-pip + - python3-pyparsing + - python3-tornado + # minimum ruby packages required for pcs ./configure --enable-tests-only + - ruby + - ruby-default-gems + - ruby-devel + - rubygem-bundler + - rubygem-io-console + - rubygem-json + - rubygem-power_assert + - rubygem-rexml + - rubygem-test-unit + - rubygems + # packages required for rpm build + - redhat-rpm-config + - rpm-build + - rpmdevtools + # packages required for pcs smoke test + - procps-ng + - shadow-utils + - util-linux + # packages required for tier1 pcs tests + - booth-site + + - name: Install python3-pycurl system package + when: distro == fedora, rhel-9, centos-stream-9 + how: install + package: + - python3-pycurl + + - name: Install dependencies for bundled python3-pycurl + # autotools_rpmbuild also bundles pycurl on fedora + when: distro >= rhel-10, fedora, centos-stream-10 + how: install + package: + - libcurl + - libcurl-devel + - openssl-devel + + - name: Install dependencies for bundled rubygem-ffi + when: distro == rhel-9, rhel-10, centos-stream-10, centos-stream-9 + how: install + package: + - gcc + - libffi-devel + + - name: Install system rubygem packages + when: distro == fedora + how: install + package: + - rubygem-backports + - rubygem-childprocess + - rubygem-ethon + - rubygem-ffi + - rubygem-mustermann + - rubygem-nio4r + - rubygem-puma + - rubygem-rack-protection + - rubygem-rack-test + - rubygem-sinatra + - rubygem-tilt + + - name: Install corosync-qdevice-devel on CentOS-Stream-9 + # required dependency for the rpm build on CentOS-Stream9 / RHEL-9.* + # provided by rhel-buildroot repository on RHEL-9.* + # missing in standard CentOS-Stream-9 repositories + # installing from 'testing-farm-tag-repository' + when: distro == centos-stream-9 + how: shell + script: + - dnf install -y --enablerepo=testing-farm-tag-repository + corosync-qdevice-devel + +# https://tmt.readthedocs.io/en/stable/spec/plans.html#execute +execute: + how: tmt + +# https://tmt.readthedocs.io/en/stable/spec/plans.html#finish +finish: + - name: Gather some system info + how: shell + script: + - mkdir -p $TMT_PLAN_DATA/misc + - cp -r /etc/yum.repos.d/ $TMT_PLAN_DATA/misc/repo_files + - rpm -qa booth* corosync* fence-agents* pacemaker* resource-agents* sbd + | tee $TMT_PLAN_DATA/misc/cluster_packages.log + - dnf repolist --all | tee $TMT_PLAN_DATA/misc/repolist.log + - dnf list --installed | tee $TMT_PLAN_DATA/misc/installed.log + - rpm -qa | sort | tee $TMT_PLAN_DATA/misc/packages.log + - lscpu | tee $TMT_PLAN_DATA/misc/lscpu.log + - free -h | tee $TMT_PLAN_DATA/misc/free.log + - "ausearch -m AVC,USER_AVC,SELINUX_ERR,USER_SELINUX_ERR -ts boot || : \ + | tee $TMT_PLAN_DATA/misc/ausearch.log" + - sysctl crypto.fips_enabled | tee $TMT_PLAN_DATA/misc/fips_status.log + + +/linters: + summary: Run linters on pcs source code + + discover+: + test: + - autotools$ + - ruff_lint + - ruff_format_check + - mypy + - typos + +/tier0: + summary: Run python and ruby tier0 tests + + discover+: + test: + - autotools$ + - python_tier0_tests + - ruby_tests + + prepare+: + - name: Make sure that tier0 tests run without cluster packages installed + how: shell + script: + - dnf remove -y 'corosync*' 'pacemaker*' 'fence-agents*' \ + 'resource-agents*' 'booth*' 'sbd*' + + +/tier1: + summary: Run distcheck, rpm_build, tier1 and smoke tests + + discover+: + test: + - autotools$ + - distcheck + - autotools_rpmbuild + - rpm_build + - python_tier1_tests + - python_smoke_tests + + provision+: + # https://tmt.readthedocs.io/en/stable/spec/hardware.html + hardware: + cpu: + processors: ">= 6" diff --git a/pylintrc b/pylintrc deleted file mode 100644 index fe92e4800..000000000 --- a/pylintrc +++ /dev/null @@ -1,43 +0,0 @@ -# Required version of pylint: see requirements.txt -# -# To install linters and their dependencies, run: -# $ make python_static_code_analysis_reqirements -# -# This project should not contain any issues reported by any linter when using -# this command to run linters on the whole project: -# $ make black_check python_static_code_analysis - -[MASTER] -extension-pkg-whitelist=lxml.etree,pycurl -load-plugins=pylint.extensions.no_self_use - -[MESSAGES CONTROL] -# consider-using-f-string - not critical, plus we use str.format() for readability -# fixme - TODO is used to mark e.g. deprecations which are to be resolved in next pcs major version -# line-too-long - handled by black -# missing-docstring - we dont require pointless docstring to be present -# trailing-whitespace - handled by black -# use-dict-literal - lot of dict() in code to be replaced, not worth the effort now -# wrong-import-order - handled by isort -disable=consider-using-f-string, fixme, line-too-long, missing-docstring, unspecified-encoding, use-dict-literal, wrong-import-order -# Everything in module context is a constant, but our naming convention allows -# constants to have the same name format as variables -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))|([a-z_][a-z0-9_]*)$ - -[DESIGN] -max-module-lines=1500 -max-args=8 -max-parents=10 -min-public-methods=0 - -[BASIC] -good-names=e, i, op, ip, el, maxDiff, cm, ok, T, dr, setUp, tearDown - -[VARIABLES] -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_$|dummy - -[FORMAT] -# Maximum number of characters on a single line. -max-line-length=80 diff --git a/pyproject.toml b/pyproject.toml index 537d8c5ad..d43e61620 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,74 @@ -[tool.black] +[tool.ruff] +# ruff settings docs: https://docs.astral.sh/ruff/settings/ line-length = 80 -target-version = ['py39'] +target-version = "py39" -[tool.isort] -profile = "black" -line_length = 80 -multi_line_output = 3 -force_grid_wrap = 2 -atomic = true -py_version = 39 -skip_gitignore = true -sections = ['FUTURE', 'STDLIB', 'THIRDPARTY', 'FIRSTPARTY', 'TESTS', 'LOCALFOLDER'] -known_first_party = ["pcs"] -known_tests = ["pcs_test"] -supported_extensions = ["py", "py.in"] +[tool.ruff.lint] +# ruff rules docs: https://docs.astral.sh/ruff/rules/ +# pylint rules in ruff: # https://github.com/astral-sh/ruff/issues/970 +select = [ + "A", + "ARG", + "ASYNC", + "B", + "C4", + "E4", + "E7", + "E9", + "F", + "G", + "I", + "LOG", + "PERF", + "PIE", + "PL", # pylint convention, error, refactoring, warning + "RET", + "SIM", + "SLF", + "SLOT", + "TC", +] +# ruff does not respect pylint ignore directives +# https://github.com/astral-sh/ruff/issues/1203 +ignore = [ + "ARG005", # https://docs.astral.sh/ruff/rules/unused-lambda-argument/ + "C408", # https://docs.astral.sh/ruff/rules/unnecessary-collection-call/ + "PERF203", # http://docs.astral.sh/ruff/rules/try-except-in-loop/ + "SIM102", # https://docs.astral.sh/ruff/rules/collapsible-if/ + "TC006", # https://docs.astral.sh/ruff/rules/runtime-cast-value/ +] +[tool.ruff.lint.flake8-builtins] +builtins-allowed-modules = [ + "json", + "logging", + "parser", + "resource", + "ssl", + "types", + "xml", +] + +[tool.ruff.lint.isort] +# known deviations from isort: +# https://docs.astral.sh/ruff/faq/#how-does-ruffs-import-sorting-compare-to-isort +# https://github.com/astral-sh/ruff/issues/1381 +# https://github.com/astral-sh/ruff/issues/2104 +known-first-party = ["pcs"] +section-order = ["future", "standard-library", "third-party", "first-party", "tests", "local-folder"] + +[tool.ruff.lint.isort.sections] +"tests" = ["pcs_test"] + +[tool.ruff.lint.per-file-ignores] +# Ignore `F401` https://docs.astral.sh/ruff/rules/unused-import/ +"__init__.py" = ["F401"] +"pcs/entry_points/*.py" = ["F401"] +"pcs/lib/cib/rule/compat_pyparsing.py" = ["F401"] +# Ignore `SLF001` https://docs.astral.sh/ruff/rules/private-member-access/ +# Ignore `SIM905` https://docs.astral.sh/ruff/rules/split-static-string/ +"pcs_test/**.py" = ["SLF001", "SIM905"] + +[tool.ruff.lint.pylint] +allow-magic-value-types = ["str", "bytes", "int"] +max-args = 8 +max-positional-args = 8 diff --git a/rpm/pcs.spec.in b/rpm/pcs.spec.in index 4e748e262..55b06f7ac 100644 --- a/rpm/pcs.spec.in +++ b/rpm/pcs.spec.in @@ -11,9 +11,10 @@ Release: 99+git%{?numcomm:.%{numcomm}}%{?alphatag:.%{alphatag}}%{?dirty:.%{dirty # GPL-2.0-only: pcs # Apache-2.0: dataclasses, tornado # Apache-2.0 or BSD-3-Clause: dateutil -# MIT: backports, childprocess, dacite, ethon, mustermann, nio4r, rack, +# MIT: backports, childprocess, dacite, ethon, mustermann, rack, # rack-protection, rack-test, sinatra, tilt -# BSD-2-Clause or Ruby: ruby2_keywords +# MIT AND (BSD-2-Clause OR GPL-2.0-or-later): nio4r +# BSD-2-Clause or Ruby: base64, ruby2_keywords, strscan # BSD 3-Clause: puma # BSD-3-Clause and MIT: ffi # Some gems we bundle are just dependencies of gems that we use, @@ -24,9 +25,16 @@ Release: 99+git%{?numcomm:.%{numcomm}}%{?alphatag:.%{alphatag}}%{?dirty:.%{dirty # nio4r # sinatra: # mustermann: -# ruby2_keywords +# ruby2_keywords (default gem - included with ruby 3.1+, part of ruby before) +# rack +# rack-protection +# rack-session # tilt -License: GPL-2.0-only AND Apache-2.0 AND MIT AND BSD-2-Clause AND BSD-3-Clause AND (Apache-2.0 OR BSD-3-CLause) AND (BSD-2-Clause OR Ruby) +# rack-protection: +# base64 (default gem before ruby3.4) +# rexml: +# strscan (default gem - included with ruby) +License: GPL-2.0-only AND Apache-2.0 AND MIT AND BSD-2-Clause AND BSD-3-Clause AND (BSD-2-Clause OR GPL-2.0-or-later) AND (Apache-2.0 OR BSD-3-Clause) AND (BSD-2-Clause OR Ruby) URL: https://github.com/ClusterLabs/pcs Summary: Pacemaker/Corosync Configuration System @@ -36,8 +44,8 @@ Summary: Pacemaker/Corosync Configuration System %global pcs_bundled_dir @pcs_bundled_dir@ -%global required_pacemaker_version 2.1.0 - +%global min_compatible_pacemaker_version 2.1.0 +%global first_incompatible_pacemaker_version 3.0.0 # mangling shebang in /usr/lib/pcsd/vendor/bundle/ruby/gems/rack-2.0.5/test/cgi/test from /usr/bin/env ruby to #!/usr/bin/ruby #*** ERROR: ./usr/lib/pcsd/vendor/bundle/ruby/gems/rack-2.0.5/test/cgi/test.ru has shebang which doesn't start with '/' (../../bin/rackup) @@ -62,18 +70,11 @@ Source41: pyagentx-%{pyagentx_version}.tar.gz @gemsrc@ # python for pcs -%if 0%{?fedora} >= 30 BuildRequires: python3 >= 3.9 BuildRequires: python3-setuptools -%endif -%if 0%{?rhel} >= 8 -BuildRequires: platform-python -BuildRequires: platform-python-setuptools # for bundled python dateutil BuildRequires: python3-setuptools_scm -%endif - BuildRequires: python3-devel # for tier0 tests BuildRequires: python3-cryptography @@ -83,6 +84,12 @@ BuildRequires: python3-wheel BuildRequires: python3-lxml BuildRequires: python3-pycurl +# for building pcs tarballs +BuildRequires: make +# printf from coreutils is used in makefile, head is used in spec +BuildRequires: coreutils +# find is used in Makefile and also somewhere else +BuildRequires: findutils # gcc for compiling custom rubygems BuildRequires: gcc BuildRequires: gcc-c++ @@ -93,20 +100,18 @@ BuildRequires: ruby-devel BuildRequires: rubygems # cluster stack packages for pkg-config BuildRequires: booth -# not distributed in rhel 9 -%if 0%{?rhel} < 9 +# Buildroot only since RHEL 9 BuildRequires: corosync-qdevice-devel -%endif BuildRequires: corosynclib-devel >= 3.0 BuildRequires: fence-agents-common %if 0%{?suse_version} %if 0%{?suse_version} > 1500 -BuildRequires: libpacemaker3-devel >= %{required_pacemaker_version} +BuildRequires: libpacemaker3-devel >= %{min_compatible_pacemaker_version}, libpacemaker3-devel < %{first_incompatible_pacemaker_version} %else -BuildRequires: libpacemaker-devel >= %{required_pacemaker_version} +BuildRequires: libpacemaker-devel >= %{min_compatible_pacemaker_version}, libpacemaker-devel < %{first_incompatible_pacemaker_version} %endif %else -BuildRequires: pacemaker-libs-devel >= %{required_pacemaker_version} +BuildRequires: pacemaker-libs-devel >= %{min_compatible_pacemaker_version}, pacemaker-libs-devel < %{first_incompatible_pacemaker_version} %endif BuildRequires: resource-agents BuildRequires: sbd @@ -118,18 +123,21 @@ BuildRequires: mozilla-nss-tools %else BuildRequires: nss-tools %endif +BuildRequires: pkgconf Requires: python3-cryptography Requires: python3-lxml Requires: python3-pycurl Requires: python3-pyparsing +# entrypoints import parts of setuptools +Requires: python3-setuptools # ruby and gems for pcsd Requires: ruby >= 2.5 Requires: rubygems # for killall Requires: psmisc # cluster stack and related packages -Requires: pacemaker >= %{required_pacemaker_version} +Requires: pacemaker >= %{min_compatible_pacemaker_version}, pacemaker < %{first_incompatible_pacemaker_version} Requires: corosync >= 3.0 # pcs enables corosync encryption by default so we require libknet1-plugins-all Requires: libknet1-plugins-all @@ -315,6 +323,7 @@ sed -i -e 's#^@#%#g' dynamic_files/pcs-snmp %{_sbindir}/pcs %{python3_sitelib}/* %{_libdir}/pcs/* +%{_libdir}/pkgconfig/pcs.pc %exclude %{_libdir}/pcs/pcs_snmp_agent %exclude %{_libdir}/pcs/%{pcs_bundled_dir}/packages/pyagentx* @@ -338,17 +347,17 @@ sed -i -e 's#^@#%#g' dynamic_files/pcs-snmp %{_unitdir}/pcsd-ruby.service # log dir -%dir %{_var}/log/pcsd +%ghost %attr(0700,root,root) %{_var}/log/pcsd # var/lib/pcsd -%{_sharedstatedir}/pcsd +%ghost %attr(0700,root,root) %{_sharedstatedir}/pcsd %ghost %config(noreplace) %attr(0600,root,root) %{_sharedstatedir}/pcsd/cfgsync_ctl %ghost %config(noreplace) %attr(0600,root,root) %{_sharedstatedir}/pcsd/known-hosts %ghost %config(noreplace) %attr(0600,root,root) %{_sharedstatedir}/pcsd/pcsd.cookiesecret %ghost %config(noreplace) %attr(0600,root,root) %{_sharedstatedir}/pcsd/pcsd.crt %ghost %config(noreplace) %attr(0600,root,root) %{_sharedstatedir}/pcsd/pcsd.key %ghost %config(noreplace) %attr(0644,root,root) %{_sharedstatedir}/pcsd/pcs_settings.conf -%ghost %config(noreplace) %attr(0644,root,root) %{_sharedstatedir}/pcsd/pcs_users.conf +%ghost %config(noreplace) %attr(0600,root,root) %{_sharedstatedir}/pcsd/pcs_users.conf # man page %{_mandir}/man8/pcsd.* diff --git a/scripts/pre-commit/README.md b/scripts/pre-commit/README.md new file mode 100644 index 000000000..8bd3c349e --- /dev/null +++ b/scripts/pre-commit/README.md @@ -0,0 +1,10 @@ +# Pre-commit hook + +It makes checks and warns the committer when there is something suspicious. + +## Install + +Just copy the main file to githooks (assume you are in project root dir): +``` +cp ./scripts/pre-commit/pre-commit.sh .git/hooks/pre-commit +``` diff --git a/scripts/pre-commit/check-all.sh b/scripts/pre-commit/check-all.sh new file mode 100755 index 000000000..243a56eb7 --- /dev/null +++ b/scripts/pre-commit/check-all.sh @@ -0,0 +1,53 @@ +#!/bin/sh + +# Check definition. Multiline string, every line is for one check. +# Format of line is: +# `Check title ./path/relative/to/this/script/check-script.sh`. +# The last space separates title from script path. +check_list=" +RUFF LINT CHECK ./check-lint.sh +RUFF FORMAT CHECK ./check-format.sh +MAKEFILE FILE LISTING CHECK ./check-makefile-file-listing.sh +" + +extract_title() { + echo "$1" | awk '{$NF=""; print $0}' +} + +extract_command() { + realpath "$(dirname "$0")"/"$(echo "$1" | awk '{print $NF}')" +} + +err_report="" +while IFS= read -r line; do + [ -n "$line" ] || continue + + check=$(extract_command "$line") + + if ! output=$("$check" 2>&1); then + err_report="${err_report}$(extract_title "$line") ($check)\n$output\n\n" + fi +done << EOF +$check_list +EOF + +if [ -z "$err_report" ]; then + exit 0 +fi + +echo "Warning: some check failed" +printf "%b" "$err_report" + +printf "Checks failed. Continue with commit? (c)Continue (a)Abort: " > /dev/tty +IFS= read -r resolution < /dev/tty + +case "$resolution" in + c | C) exit 0 ;; + a | A) + echo "Commit aborted." + exit 1 + ;; +esac + +echo "Unknown resolution '$resolution', aborting commit..." +exit 1 diff --git a/scripts/pre-commit/check-format.sh b/scripts/pre-commit/check-format.sh new file mode 100755 index 000000000..dd6e4ef31 --- /dev/null +++ b/scripts/pre-commit/check-format.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if make --dry-run ruff_format_check > /dev/null 2>&1; then + make ruff_format_check +else + echo "No 'make ruff_format_check', skipping..." +fi diff --git a/scripts/pre-commit/check-lint.sh b/scripts/pre-commit/check-lint.sh new file mode 100755 index 000000000..89ab675bc --- /dev/null +++ b/scripts/pre-commit/check-lint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if make --dry-run ruff_lint > /dev/null 2>&1; then + make ruff_lint +else + echo "No 'make ruff_lint', skipping..." +fi diff --git a/scripts/pre-commit/check-makefile-file-listing.sh b/scripts/pre-commit/check-makefile-file-listing.sh new file mode 100755 index 000000000..fd81d1109 --- /dev/null +++ b/scripts/pre-commit/check-makefile-file-listing.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +makefile_list="\ +Makefile.am +pcs/Makefile.am +pcs_test/Makefile.am +pcsd/Makefile.am +data/Makefile.am +" + +get_mentioned_files() { + for makefile in $1; do + [ -n "$makefile" ] || continue + "$(dirname "$0")"/extract-extra-dist.sh "$makefile" + done +} + +get_unlisted_files() { + makefiles=$1 + added_files=$2 + + mentioned_files=$(get_mentioned_files "$makefiles") + + for file in $added_files; do + if ! echo "$mentioned_files" | + grep --quiet --fixed-strings --line-regexp "$file" 2> /dev/null; then + echo "$file" + fi + done +} + +git_added="$(git diff --cached --name-only --diff-filter=A)" + +if [ -z "$git_added" ]; then + exit 0 +fi + +unlisted_files="$(get_unlisted_files "$makefile_list" "$git_added")" + +if [ -z "$unlisted_files" ]; then + exit 0 +fi + +echo "Warning: The following files are not listed in any Makefile.am:" +echo "$unlisted_files" +exit 1 diff --git a/scripts/pre-commit/extract-extra-dist.sh b/scripts/pre-commit/extract-extra-dist.sh new file mode 100755 index 000000000..16608e721 --- /dev/null +++ b/scripts/pre-commit/extract-extra-dist.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +makefile="$1" +inside_extra_dist=0 +files="" + +while IFS= read -r line; do + case "$line" in + *EXTRA_DIST[[:space:]]*=*) + inside_extra_dist=1 + files="${line#*=}" + files="${files%\\}" + ;; + *\\) + [ $inside_extra_dist -eq 1 ] && files="$files ${line%\\}" + ;; + *) # the last line in EXTRA_DIST + [ $inside_extra_dist -eq 1 ] && files="$files $line" && break + ;; + esac +done < "$makefile" + +# no globing +set -f +# split to arguments +# shellcheck disable=2086 +set -- $files +for file in "$@"; do + printf "%s\n" "${makefile%Makefile.am}$file" +done diff --git a/scripts/pre-commit/pre-commit.sh b/scripts/pre-commit/pre-commit.sh new file mode 100755 index 000000000..6361e878a --- /dev/null +++ b/scripts/pre-commit/pre-commit.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +checks=./scripts/pre-commit/check-all.sh + +if [ -x "$checks" ]; then + "$checks" +fi diff --git a/tests.fmf b/tests.fmf new file mode 100644 index 000000000..31e4cb1ce --- /dev/null +++ b/tests.fmf @@ -0,0 +1,118 @@ +# https://tmt.readthedocs.io/en/stable/spec/tests.html +component: pcs +duration: 30m +path: / +environment: + CONFIGURE_OPTIONS: >- + --enable-local-build + --enable-individual-bundling + --enable-dev-tests + --enable-webui + --enable-destructive-tests + CONFIGURE_OPTIONS_DISTCHECK: >- + --enable-local-build + --enable-individual-bundling + CONFIGURE_OPTIONS_RPM_BUILD: >- + --enable-local-build + --enable-webui + --enable-destructive-tests + +/autotools: + summary: Build pcs to run tests + tag: [autotools, distcheck, lint, python, ruby, tier0] + test: | + ./autogen.sh && + ./configure $CONFIGURE_OPTIONS && + make + +/ruff_lint: + summary: Run ruff linter + tag: [lint] + test: make ruff_lint + +/ruff_format_check: + summary: Run ruff format check + tag: [lint] + test: make ruff_format_check + +/mypy: + summary: Run mypy type checking + tag: [lint] + test: make mypy + +/typos: + summary: Run typos check + tag: [lint] + test: make typos_check + +/python_tier0_tests: + summary: Run python tier0 tests + tag: [tier0, python] + test: make tests_tier0 + +/ruby_tests: + summary: Run ruby tier0 tests + tag: [tier0, ruby] + test: make pcsd-tests + +/distcheck: + summary: Run make distcheck + tag: [distcheck] + test: | + make distcheck DISTCHECK_CONFIGURE_FLAGS="$CONFIGURE_OPTIONS_DISTCHECK" && + rename --verbose .tar. ".${COMPOSE_NAME}.tar." pcs*.tar.* && + mkdir -p dist && + cp -v pcs*.tar.* dist/ && + cp -rv dist $TMT_PLAN_DATA && + cp -rv dist $TMT_TEST_DATA + +/autotools_rpmbuild: + summary: Configure pcs to run rpm_build, tier1 and smoke tests + tag: [autotools_rpmbuild, rpm_build, smoke, tier1] + test: | + ./autogen.sh && + ./configure $CONFIGURE_OPTIONS_RPM_BUILD && + make + +/rpm_build: + summary: Run make rpm to build a pcs package + tag: [rpm_build, smoke, tier1] + test: | + make CI_BRANCH=${COMPOSE_NAME} rpm/pcs.spec && + dnf builddep -y rpm/pcs.spec && + make CI_BRANCH=${COMPOSE_NAME} rpm && + mkdir -p rpms && + cp -v $(find rpm -type f -name '*.rpm' -not -name '*.src.rpm') rpms && + cp -rv rpms $TMT_PLAN_DATA && + cp -rv rpms $TMT_TEST_DATA + +/python_tier1_tests: + summary: Install pcs rpm and run pcs python tier1 tests + tag: [tier1] + duration: 1h + # installing built pcs.rpm installs pacemaker, corosync, etc. as dependencies + # rm -rf pcs - to make sure we are testing installed packages and not sources + test: | + dnf install -y \ + $TMT_PLAN_DATA/rpms/pcs-*${COMPOSE_NAME}*$(rpm -E %{dist}).*.rpm && + rm -rf pcs pcsd pcs_bundled && + pcs_test/suite -v --installed --tier1 + +/python_smoke_tests: + summary: Install pcs rpm and run pcs smoke tests + tag: [smoke] + # installing built pcs.rpm installs pacemaker, corosync, etc. as dependencies + # rm -rf pcs - to make sure we are testing installed packages and not sources + test: | + dnf install -y \ + $TMT_PLAN_DATA/rpms/pcs-*${COMPOSE_NAME}*$(rpm -E %{dist}).*.rpm && + rm -rf pcs pcsd pcs_bundled && + sed -n -i "s/^PCSD_DEBUG=.*/PCSD_DEBUG=true/p" /etc/sysconfig/pcsd && + systemctl start pcsd && + sleep 2 && + EXIT_CODE=0 && + pcs_test/smoke.sh || EXIT_CODE=$? && + mkdir -p {$TMT_PLAN_DATA,$TMT_TEST_DATA}/var/log/ && + cp -rv /var/log/pcsd $TMT_PLAN_DATA/var/log && + cp -rv /var/log/pcsd $TMT_TEST_DATA/var/log && + exit $EXIT_CODE diff --git a/typos.toml b/typos.toml index ef74964d7..73b8ec667 100644 --- a/typos.toml +++ b/typos.toml @@ -25,6 +25,7 @@ muttualy = "mutually" operatin = "operation" oudside = "outside" pacakamer = "pacemaker" +pacemkaer = "pacemaker" pririty = "priority" proprties = "properties" quourm = "quorum" diff --git a/typos_known b/typos_known index a4eb38cc1..1c432c1ec 100644 --- a/typos_known +++ b/typos_known @@ -1,113 +1,114 @@ -./CHANGELOG.md: `regardles` -> `regardless` -./Makefile.am: `ba` -> `by`, `be` -./pcs/cli/common/output.py: `substracted` -> `subtracted` -./pcs/cli/constraint/command.py: `alowed` -> `allowed` -./pcs/cli/constraint/command.py: `alowed` -> `allowed` -./pcs/cli/constraint_ticket/command.py: `alowed` -> `allowed` -./pcs/cli/constraint_ticket/command.py: `alowed` -> `allowed` -./pcs/common/reports/deprecated_codes.py: `OVERRIDEN` -> `OVERRIDDEN` -./pcs/common/reports/deprecated_codes.py: `OVERRIDEN` -> `OVERRIDDEN` -./pcs/common/reports/messages.py: `disabl` -> `disable` -./pcs/common/reports/messages.py: `stopp` -> `stop` -./pcsd/test/test_cluster_entity.rb: `primitve` -> `primitive` -./pcs/lib/commands/constraint/common.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/common.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/common.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/common.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/common.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/common.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/ticket.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/ticket.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/ticket.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/ticket.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/ticket.py: `alowed` -> `allowed` -./pcs/lib/commands/constraint/ticket.py: `alowed` -> `allowed` -./pcs/snmp/mibs/PCMK-PCS-V1-MIB.txt: `Resrouces` -> `Resources` -./pcs/snmp/mibs/PCMK-PCS-V1-MIB.txt: `Resrouces` -> `Resources` -./pcs/snmp/mibs/PCMK-PCS-V1-MIB.txt: `Resrouces` -> `Resources` -./pcs/snmp/mibs/PCMK-PCS-V1-MIB.txt: `Resrouces` -> `Resources` -./pcs_test/tier0/cli/constraint_ticket/test_command.py: `alowed` -> `allowed` -./pcs_test/tier0/cli/constraint_ticket/test_command.py: `alowed` -> `allowed` -./pcs_test/tier0/cli/constraint_ticket/test_command.py: `alowed` -> `allowed` -./pcs_test/tier0/cli/constraint_ticket/test_command.py: `alowed` -> `allowed` -./pcs_test/tier0/cli/test_cluster.py: `ba` -> `by`, `be` -./pcs_test/tier0/cli/test_cluster.py: `ba` -> `by`, `be` -./pcs_test/tier0/cli/test_cluster.py: `ba` -> `by`, `be` -./pcs_test/tier0/cli/test_cluster.py: `ba` -> `by`, `be` -./pcs_test/tier0/cli/test_cluster.py: `ba` -> `by`, `be` -./pcs_test/tier0/cli/test_cluster.py: `ba` -> `by`, `be` -./pcs_test/tier0/lib/cib/test_resource_operations.py: `monitro` -> `monitor` -./pcs_test/tier0/lib/cib/test_resource_operations.py: `monitro` -> `monitor` -./pcs_test/tier0/lib/cib/test_tools.py: `ba` -> `by`, `be` -./pcs_test/tier0/lib/cib/test_tools.py: `ba` -> `by`, `be` -./pcs_test/tier0/lib/cib/test_tools.py: `ba` -> `by`, `be` -./pcs_test/tier0/lib/commands/test_constraint_common.py: `alowed` -> `allowed` -./pcs_test/tier0/lib/corosync/test_config_parser.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_parser.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_parser.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_parser.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_common.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_create.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_create.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_create.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_create.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_links.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_links.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_links.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_links.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_links.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_links.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_quorum.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/corosync/test_config_validators_quorum.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/pacemaker/test_values.py: `Alse` -> `Else`, `Also`, `False` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/pacemaker/test_values.py: `dum` -> `dumb` -./pcs_test/tier0/lib/test_env_corosync.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/test_env_corosync.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/test_env_corosync.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/test_env_corosync.py: `ue` -> `use`, `due` -./pcs_test/tier0/lib/test_validate.py: `Ba` -> `By`, `Be` -./pcs_test/tier0/lib/test_validate.py: `Ba` -> `By`, `Be` -./pcs_test/tier1/cib_resource/test_create.py: `monitro` -> `monitor` -./pcs_test/tier1/cib_resource/test_create.py: `monitro` -> `monitor` -./pcs_test/tier1/cib_resource/test_create.py: `monitro` -> `monitor` -./pcs_test/tier1/cib_resource/test_create.py: `monitro` -> `monitor` -./pcs_test/tier1/cib_resource/test_create.py: `monitro` -> `monitor` -./pcs_test/tier1/cib_resource/test_create.py: `monitro` -> `monitor` -./pcs_test/tier1/legacy/test_utils.py: `dum` -> `dumb` -./pcs_test/tier1/legacy/test_utils.py: `dum` -> `dumb` -./pcs_test/tier1/legacy/test_utils.py: `dum` -> `dumb` -./pcs_test/tier1/legacy/test_utils.py: `dum` -> `dumb` -./pcs_test/tier1/legacy/test_utils.py: `dum` -> `dumb` -./pcs_test/tier1/legacy/test_utils.py: `dum` -> `dumb` -./pcs_test/tier1/legacy/test_utils.py: `dum` -> `dumb` -./pcs_test/tier1/legacy/test_utils.py: `dum` -> `dumb` -./pcs_test/tier1/legacy/test_utils.py: `ue` -> `use`, `due` -./pcs_test/tier1/legacy/test_utils.py: `ue` -> `use`, `due` -./pcs_test/tools/bin_mock/pcmk/crm_resource.d/list_agents_ocf__heartbeat: `exportfs` -> `exports` +./CHANGELOG.md: error: `regardles` should be `regardless` +./CHANGELOG.md: error: `unaccessible` should be `inaccessible` +./Makefile.am: error: `ba` should be `by`, `be` +./pcs/cli/common/output.py: error: `substracted` should be `subtracted` +./pcs/cli/constraint/command.py: error: `alowed` should be `allowed` +./pcs/cli/constraint/command.py: error: `alowed` should be `allowed` +./pcs/cli/constraint_ticket/command.py: error: `alowed` should be `allowed` +./pcs/cli/constraint_ticket/command.py: error: `alowed` should be `allowed` +./pcs/common/reports/deprecated_codes.py: error: `OVERRIDEN` should be `OVERRIDDEN` +./pcs/common/reports/deprecated_codes.py: error: `OVERRIDEN` should be `OVERRIDDEN` +./pcs/common/reports/messages.py: error: `disabl` should be `disable` +./pcs/common/reports/messages.py: error: `stopp` should be `stop` +./pcsd/conf/pcsd: error: `sesions` should be `sessions` +./pcsd/test/test_cluster_entity.rb: error: `primitve` should be `primitive` +./pcs/lib/commands/constraint/common.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/common.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/common.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/common.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/common.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/common.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/ticket.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/ticket.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/ticket.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/ticket.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/ticket.py: error: `alowed` should be `allowed` +./pcs/lib/commands/constraint/ticket.py: error: `alowed` should be `allowed` +./pcs/snmp/mibs/PCMK-PCS-V1-MIB.txt: error: `Resrouces` should be `Resources` +./pcs/snmp/mibs/PCMK-PCS-V1-MIB.txt: error: `Resrouces` should be `Resources` +./pcs/snmp/mibs/PCMK-PCS-V1-MIB.txt: error: `Resrouces` should be `Resources` +./pcs/snmp/mibs/PCMK-PCS-V1-MIB.txt: error: `Resrouces` should be `Resources` +./pcs_test/tier0/cli/constraint_ticket/test_command.py: error: `alowed` should be `allowed` +./pcs_test/tier0/cli/constraint_ticket/test_command.py: error: `alowed` should be `allowed` +./pcs_test/tier0/cli/constraint_ticket/test_command.py: error: `alowed` should be `allowed` +./pcs_test/tier0/cli/constraint_ticket/test_command.py: error: `alowed` should be `allowed` +./pcs_test/tier0/cli/test_cluster.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/cli/test_cluster.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/cli/test_cluster.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/cli/test_cluster.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/cli/test_cluster.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/cli/test_cluster.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/lib/cib/test_resource_operations.py: error: `monitro` should be `monitor` +./pcs_test/tier0/lib/cib/test_resource_operations.py: error: `monitro` should be `monitor` +./pcs_test/tier0/lib/cib/test_tools.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/lib/cib/test_tools.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/lib/cib/test_tools.py: error: `ba` should be `by`, `be` +./pcs_test/tier0/lib/commands/test_constraint_common.py: error: `alowed` should be `allowed` +./pcs_test/tier0/lib/corosync/test_config_parser.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_parser.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_parser.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_parser.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_common.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_create.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_create.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_create.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_create.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_links.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_links.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_links.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_links.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_links.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_links.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_quorum.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/corosync/test_config_validators_quorum.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `Alse` should be `Else`, `Also`, `False` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/pacemaker/test_values.py: error: `dum` should be `dumb` +./pcs_test/tier0/lib/test_env_corosync.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/test_env_corosync.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/test_env_corosync.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/test_env_corosync.py: error: `ue` should be `use`, `due` +./pcs_test/tier0/lib/test_validate.py: error: `Ba` should be `By`, `Be` +./pcs_test/tier0/lib/test_validate.py: error: `Ba` should be `By`, `Be` +./pcs_test/tier1/cib_resource/test_create.py: error: `monitro` should be `monitor` +./pcs_test/tier1/cib_resource/test_create.py: error: `monitro` should be `monitor` +./pcs_test/tier1/cib_resource/test_create.py: error: `monitro` should be `monitor` +./pcs_test/tier1/cib_resource/test_create.py: error: `monitro` should be `monitor` +./pcs_test/tier1/cib_resource/test_create.py: error: `monitro` should be `monitor` +./pcs_test/tier1/cib_resource/test_create.py: error: `monitro` should be `monitor` +./pcs_test/tier1/legacy/test_utils.py: error: `dum` should be `dumb` +./pcs_test/tier1/legacy/test_utils.py: error: `dum` should be `dumb` +./pcs_test/tier1/legacy/test_utils.py: error: `dum` should be `dumb` +./pcs_test/tier1/legacy/test_utils.py: error: `dum` should be `dumb` +./pcs_test/tier1/legacy/test_utils.py: error: `dum` should be `dumb` +./pcs_test/tier1/legacy/test_utils.py: error: `dum` should be `dumb` +./pcs_test/tier1/legacy/test_utils.py: error: `dum` should be `dumb` +./pcs_test/tier1/legacy/test_utils.py: error: `dum` should be `dumb` +./pcs_test/tier1/legacy/test_utils.py: error: `ue` should be `use`, `due` +./pcs_test/tier1/legacy/test_utils.py: error: `ue` should be `use`, `due`