diff --git a/actions.yaml b/actions.yaml index 6405af313..851444905 100644 --- a/actions.yaml +++ b/actions.yaml @@ -1,20 +1,59 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -resume-upgrade: - description: Upgrade remaining units (after you manually verified that upgraded units are healthy). +pre-refresh-check: + description: Check if charm is ready to refresh +force-refresh-start: + description: | + Potential of data loss and downtime + + Force refresh of first unit + + Must run with at least one of the parameters `=false` params: - force: + check-compatibility: type: boolean - default: false + default: true description: | - Potential of *data loss* and *downtime* + Potential of data loss and downtime - Force upgrade of next unit. + If `false`, force refresh if new version of MySQL Router and/or charm is not compatible with previous version + run-pre-refresh-checks: + type: boolean + default: true + description: | + Potential of data loss and downtime + + If `false`, force refresh if app is unhealthy or not ready to refresh (and unit status shows "Pre-refresh check failed") + check-workload-container: + type: boolean + default: true + description: | + Potential of data loss and downtime during and after refresh + + If `false`, allow refresh to MySQL Router container version that has not been validated to work with the charm revision + required: [] +resume-refresh: + description: | + Refresh next unit(s) (after you have manually verified that refreshed units are healthy) + + If the `pause_after_unit_refresh` config is set to `all`, this action will refresh the next unit. + + If `pause_after_unit_refresh` is set to `first`, this action will refresh all remaining units. + Exception: if automatic health checks fail after a unit has refreshed, the refresh will pause. + + If `pause_after_unit_refresh` is set to `none`, this action will have no effect unless it is called with `check-health-of-refreshed-units` as `false`. + params: + check-health-of-refreshed-units: + type: boolean + default: true + description: | + Potential of data loss and downtime + + If `false`, force refresh (of next unit) if 1 or more refreshed units are unhealthy - Use to - - force incompatible upgrade and/or - - continue upgrade if 1+ upgraded units have non-active status + Warning: if first unit to refresh is unhealthy, consider running `force-refresh-start` action on that unit instead of using this parameter. + If first unit to refresh is unhealthy because compatibility checks, pre-refresh checks, or workload container checks are failing, this parameter is more destructive than the `force-refresh-start` action. required: [] set-tls-private-key: diff --git a/charm_version b/charm_version deleted file mode 100644 index 5625e59da..000000000 --- a/charm_version +++ /dev/null @@ -1 +0,0 @@ -1.2 diff --git a/charmcraft.yaml b/charmcraft.yaml index ff8333131..b97be43d2 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -51,7 +51,7 @@ parts: source: . after: - poetry-deps - poetry-export-extra-args: ['--only', 'main,charm-libs'] + poetry-export-extra-args: ['--only', 'main,charm-libs', '--without-hashes'] # TODO: re-enable hashes build-packages: - libffi-dev # Needed to build Python dependencies with Rust from source - libssl-dev # Needed to build Python dependencies with Rust from source @@ -86,16 +86,10 @@ parts: build-packages: - git override-build: | - # Workaround to add unique identifier (git hash) to charm version while specification - # DA053 - Charm versioning - # (https://docs.google.com/document/d/1Jv1jhWLl8ejK3iJn7Q3VbCIM9GIhp8926bgXpdtx-Sg/edit?pli=1) - # is pending review. - python3 -c 'import pathlib; import shutil; import subprocess; git_hash=subprocess.run(["git", "describe", "--always", "--dirty"], capture_output=True, check=True, encoding="utf-8").stdout; file = pathlib.Path("charm_version"); shutil.copy(file, pathlib.Path("charm_version.backup")); version = file.read_text().strip(); file.write_text(f"{version}+{git_hash}")' - + # TODO: set charm version in refresh_versions.toml craftctl default stage: - LICENSE - - charm_version - - workload_version + - refresh_versions.toml - scripts - templates diff --git a/config.yaml b/config.yaml index 001f56ecd..a6cf406db 100644 --- a/config.yaml +++ b/config.yaml @@ -15,3 +15,11 @@ options: and managed by the charm. type: string default: "{}" + + pause_after_unit_refresh: + description: | + Wait for manual confirmation to resume refresh after these units refresh + + Allowed values: "all", "first", "none" + type: string + default: first diff --git a/metadata.yaml b/metadata.yaml index 7e5680212..a75205a53 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -46,11 +46,8 @@ requires: peers: cos: interface: cos - upgrade-version-a: - # Relation versioning scheme: - # DA056 - Upgrading in-place upgrade protocol - # https://docs.google.com/document/d/1H7qy5SAwLiCOKO9xMQJbbQP5_-jGV6Lhi-mJOk4gZ08/edit - interface: upgrade + refresh-v-three: + interface: refresh mysql-router-peers: interface: mysql_router_peers resources: diff --git a/poetry.lock b/poetry.lock index 8e49c1808..db28ce861 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "allure-pytest" @@ -141,7 +141,7 @@ description = "Base class for creating enumerated constants that are also subcla optional = false python-versions = ">=3.8.6,<3.11" groups = ["integration"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "backports_strenum-1.3.1-py3-none-any.whl", hash = "sha256:cdcfe36dc897e2615dc793b7d3097f54d359918fc448754a517e6f23044ccf83"}, {file = "backports_strenum-1.3.1.tar.gz", hash = "sha256:77c52407342898497714f0596e86188bb7084f89063226f4ba66863482f42414"}, @@ -278,6 +278,59 @@ markers = {charm-libs = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = "*" +[[package]] +name = "charm-api" +version = "0.1.1" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "charm_api-0.1.1-py3-none-any.whl", hash = "sha256:2fb02cee06a198e025a9a25f9e2b80bdecac62e07f0e0b9dca217031328184aa"}, + {file = "charm_api-0.1.1.tar.gz", hash = "sha256:8e55e6ae4b484548a6c48eb83d68af39a77910c1aff9596b13ddc7c1e319fabc"}, +] + +[[package]] +name = "charm-json" +version = "0.1.1" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "charm_json-0.1.1-py3-none-any.whl", hash = "sha256:a3fac62d45821d1a8c14058632e21333ec4e2cd41d0d00d6a73d00fc9a656eef"}, + {file = "charm_json-0.1.1.tar.gz", hash = "sha256:cb2eb24f6135d226ad04b0a17288ca2e027160d8af288083ef701bf4b137154e"}, +] + +[package.dependencies] +charm-api = ">=0.1.1" + +[[package]] +name = "charm-refresh" +version = "0.1.0" +description = "In-place rolling refreshes of stateful charmed applications " +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +charm-api = ">=0.1.1" +charm-json = ">=0.1.1" +httpx = ">=0.28.1" +lightkube = ">=0.15.4" +ops = ">=2.9.0" +packaging = ">=24.1" +pyyaml = ">=6.0.2" +tomli = ">=2.0.1" + +[package.source] +type = "git" +url = "https://github.com/canonical/charm-refresh" +reference = "draft" +resolved_reference = "6d4e6a9bc41a943ed8be6a0c6326d2d9d461ecab" + [[package]] name = "charset-normalizer" version = "3.3.2" @@ -584,7 +637,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "charm-libs", "integration", "unit"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, @@ -701,14 +754,14 @@ trio = ["trio (>=0.22.0,<0.26.0)"] [[package]] name = "httpx" -version = "0.27.0" +version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" groups = ["main", "charm-libs"] files = [ - {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, - {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] [package.dependencies] @@ -716,13 +769,13 @@ anyio = "*" certifi = "*" httpcore = "==1.*" idna = "*" -sniffio = "*" [package.extras] brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "hvac" @@ -977,18 +1030,18 @@ adal = ["adal (>=1.0.2)"] [[package]] name = "lightkube" -version = "0.15.8" +version = "0.15.6" description = "Lightweight kubernetes client library" optional = false python-versions = "*" groups = ["main", "charm-libs"] files = [ - {file = "lightkube-0.15.8-py3-none-any.whl", hash = "sha256:236f6d11e9281764a8ae896ab2c28a4bc943dc0576822445064577eaa90677ba"}, - {file = "lightkube-0.15.8.tar.gz", hash = "sha256:ac950d24ddbb59904708730f13ce254b05b6255a471dfab027cbe44c4123bfc6"}, + {file = "lightkube-0.15.6-py3-none-any.whl", hash = "sha256:295ae45aebb300dbb6014672ec794966505072378ec488096916009714384a04"}, + {file = "lightkube-0.15.6.tar.gz", hash = "sha256:a12908bf2ec122bf0e0708c432af72af2c812dcedec1ba38ca65fca449b346ea"}, ] [package.dependencies] -httpx = ">=0.24.0,<0.28.0" +httpx = ">=0.24.0,<0.28.0 || >0.28.0,<1.0.0" lightkube-models = ">=1.15.12.0" PyYAML = "*" @@ -1322,7 +1375,7 @@ version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["integration", "unit"] +groups = ["main", "integration", "unit"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, @@ -1882,63 +1935,65 @@ files = [ [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" groups = ["main", "charm-libs", "integration", "unit"] files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] @@ -2215,12 +2270,12 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" -groups = ["integration", "unit"] +groups = ["main", "integration", "unit"] files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] -markers = {integration = "python_version < \"3.11\"", unit = "python_full_version <= \"3.11.0a6\""} +markers = {integration = "python_version == \"3.10\"", unit = "python_full_version <= \"3.11.0a6\""} [[package]] name = "toposort" @@ -2261,7 +2316,7 @@ files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {main = "python_version < \"3.11\""} +markers = {main = "python_version == \"3.10\""} [[package]] name = "typing-inspect" @@ -2504,4 +2559,4 @@ test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.funct [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "895b46d1fc9234024ff98a491ce6bcd529747152bf38f7f413fab78c85e67dd8" +content-hash = "13754ccc5de08ba0ecdb4a28f035bd96e7f650b39ef14edd054045da8a6dbc87" diff --git a/pyproject.toml b/pyproject.toml index 4636d172a..7d7b73fda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ tenacity = "^8.5.0" jinja2 = "^3.1.4" poetry-core = "^1.9.0" requests = "^2.32.3" +charm-refresh = {git = "https://github.com/canonical/charm-refresh", rev = "draft"} [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py diff --git a/refresh_versions.toml b/refresh_versions.toml new file mode 100644 index 000000000..d852d9fdf --- /dev/null +++ b/refresh_versions.toml @@ -0,0 +1,5 @@ +charm_major = 1 +workload = "8.0.41" + +# autogenerated +charm = "8.0/1.0.0" diff --git a/src/abstract_charm.py b/src/abstract_charm.py index d7d400113..d0492d5b8 100644 --- a/src/abstract_charm.py +++ b/src/abstract_charm.py @@ -4,26 +4,54 @@ """MySQL Router charm""" import abc +import dataclasses import logging import typing +import charm_refresh import ops import container import lifecycle import logrotate -import machine_upgrade import relations.cos import relations.database_provides import relations.database_requires import relations.tls import server_exceptions -import upgrade import workload logger = logging.getLogger(__name__) +@dataclasses.dataclass(eq=False) +class RouterRefresh(charm_refresh.CharmSpecificCommon, abc.ABC): + """MySQL Router refresh callbacks & configuration""" + + @staticmethod + def run_pre_refresh_checks_after_1_unit_refreshed() -> None: + pass + + @classmethod + def is_compatible( + cls, + *, + old_charm_version: charm_refresh.CharmVersion, + new_charm_version: charm_refresh.CharmVersion, + old_workload_version: str, + new_workload_version: str, + ) -> bool: + if not super().is_compatible( + old_charm_version=old_charm_version, + new_charm_version=new_charm_version, + old_workload_version=old_workload_version, + new_workload_version=new_workload_version, + ): + return False + # TODO: check workload version—prevent downgrade? + return True + + class MySQLRouterCharm(ops.CharmBase, abc.ABC): """MySQL Router charm""" @@ -32,6 +60,14 @@ class MySQLRouterCharm(ops.CharmBase, abc.ABC): _READ_WRITE_X_PORT = 6448 _READ_ONLY_X_PORT = 6449 + refresh: charm_refresh.Common + # Whether `reconcile` method is allowed to run + # `False` if `charm_refresh.UnitTearingDown` or `charm_refresh.PeerRelationNotReady` raised + # Most of the charm code should not run if either of those exceptions is raised + # However, some charm libs (i.e. data-platform-libs) will break if they do not receive every + # event they expect (e.g. relation-created) + _reconcile_allowed: bool + def __init__(self, *args) -> None: super().__init__(*args) # Instantiate before registering other event observers @@ -40,33 +76,19 @@ def __init__(self, *args) -> None: ) self._workload_type = workload.Workload - self._authenticated_workload_type = workload.AuthenticatedWorkload + self._running_workload_type = workload.RunningWorkload self._database_requires = relations.database_requires.RelationEndpoint(self) self._database_provides = relations.database_provides.RelationEndpoint(self) self._cos_relation = relations.cos.COSRelation(self, self._container) self._ha_cluster = None - self.framework.observe(self.on.update_status, self.reconcile) - self.framework.observe( - self.on[upgrade.PEER_RELATION_ENDPOINT_NAME].relation_changed, self.reconcile - ) - self.framework.observe( - self.on[upgrade.RESUME_ACTION_NAME].action, self._on_resume_upgrade_action - ) - # (For Kubernetes) Reset partition after scale down - self.framework.observe( - self.on[upgrade.PEER_RELATION_ENDPOINT_NAME].relation_departed, self.reconcile - ) - # Handle upgrade & set status on first start if no relations active - self.framework.observe(self.on.start, self.reconcile) - # Update app status - self.framework.observe(self.on.leader_elected, self.reconcile) - # Set versions in upgrade peer relation app databag - self.framework.observe( - self.on[upgrade.PEER_RELATION_ENDPOINT_NAME].relation_created, - self._upgrade_relation_created, - ) self.tls = relations.tls.RelationEndpoint(self) + # Observe all events (except custom events) + for bound_event in self.on.events().values(): + if bound_event.event_type == ops.CollectStatusEvent: + continue + self.framework.observe(bound_event, self.reconcile) + @property @abc.abstractmethod def _subordinate_relation_endpoint_names(self) -> typing.Optional[typing.Iterable[str]]: @@ -80,11 +102,6 @@ def _subordinate_relation_endpoint_names(self) -> typing.Optional[typing.Iterabl def _container(self) -> container.Container: """Workload container (snap or rock)""" - @property - @abc.abstractmethod - def _upgrade(self) -> typing.Optional[upgrade.Upgrade]: - pass - @property @abc.abstractmethod def _logrotate(self) -> logrotate.LogRotate: @@ -162,10 +179,17 @@ def _cos_exporter_config(self, event) -> typing.Optional[relations.cos.ExporterC if cos_relation_exists: return self._cos_relation.exporter_user_config - def get_workload(self, *, event): - """MySQL Router workload""" - if connection_info := self._database_requires.get_connection_info(event=event): - return self._authenticated_workload_type( + def get_workload(self, *, event, refresh: charm_refresh.Common = None): + """MySQL Router workload + + Pass `refresh` if `self.refresh` is not set + """ + if refresh is None: + refresh = self.refresh + if refresh.workload_allowed_to_start and ( + connection_info := self._database_requires.get_connection_info(event=event) + ): + return self._running_workload_type( container_=self._container, logrotate_=self._logrotate, connection_info=connection_info, @@ -198,11 +222,8 @@ def _prioritize_statuses(statuses: typing.List[ops.StatusBase]) -> ops.StatusBas def _determine_app_status(self, *, event) -> ops.StatusBase: """Report app status.""" - if self._upgrade and (upgrade_status := self._upgrade.app_status): - # Upgrade status should take priority over relation status—even if the status level is - # normally lower priority. - # (Relations should not be modified during upgrade.) - return upgrade_status + if self.refresh.app_status_higher_priority: + return self.refresh.app_status_higher_priority statuses = [] if self._status: statuses.append(self._status) @@ -213,14 +234,21 @@ def _determine_app_status(self, *, event) -> ops.StatusBase: def _determine_unit_status(self, *, event) -> ops.StatusBase: """Report unit status.""" + if self.refresh.unit_status_higher_priority: + return self.refresh.unit_status_higher_priority statuses = [] - workload_status = self.get_workload(event=event).status - if self._upgrade: - statuses.append(self._upgrade.get_unit_juju_status(workload_status=workload_status)) + workload_ = self.get_workload(event=event) + if status := workload_.status: + statuses.append(status) # only in machine charms if self._ha_cluster: - statuses.append(self._ha_cluster.get_unit_juju_status()) - statuses.append(workload_status) + if status := self._ha_cluster.get_unit_juju_status(): + statuses.append(status) + refresh_lower_priority = self.refresh.unit_status_lower_priority( + workload_is_running=isinstance(workload_, workload.RunningWorkload) + ) + if (not statuses or statuses == [ops.WaitingStatus()]) and refresh_lower_priority: + return refresh_lower_priority return self._prioritize_statuses(statuses) def set_status(self, *, event, app=True, unit=True) -> None: @@ -261,67 +289,30 @@ def _update_endpoints(self) -> None: # Handlers # ======================= - def _upgrade_relation_created(self, _) -> None: - if self._unit_lifecycle.authorized_leader: - # `self._upgrade.is_compatible` should return `True` during first charm - # installation/setup - self._upgrade.set_versions_in_app_databag() - def reconcile(self, event=None) -> None: # noqa: C901 """Handle most events.""" - if not self._upgrade: - logger.debug("Peer relation not available") - return - if not self._upgrade.versions_set: - logger.debug("Peer relation not ready") + if not self._reconcile_allowed: + logger.debug("Reconcile not allowed") return workload_ = self.get_workload(event=event) - if self._unit_lifecycle.authorized_leader and not self._upgrade.in_progress: - # Run before checking `self._upgrade.is_compatible` in case incompatible upgrade was - # forced & completed on all units. - # Side effect: on machines, if charm was upgraded to a charm with the same snap - # revision, compatibility checks will be skipped. - # (The only real use case for this would be upgrading the charm code to an incompatible - # version without upgrading the snap. In that situation, the upgrade may appear - # successful and the user will not be notified of the charm incompatibility. This case - # is much less likely than the forced incompatible upgrade & the impact is not as bad - # as the impact if we did not handle the forced incompatible upgrade case.) - self._upgrade.set_versions_in_app_databag() - if self._upgrade.unit_state is upgrade.UnitState.RESTARTING: # Kubernetes only - if not self._upgrade.is_compatible: - logger.info( - "Upgrade incompatible. If you accept potential *data loss* and *downtime*, you can continue with `resume-upgrade force=true`" - ) - self.unit.status = ops.BlockedStatus( - "Upgrade incompatible. Rollback to previous revision with `juju refresh`" - ) - self.set_status(event=event, unit=False) - return - elif isinstance(self._upgrade, machine_upgrade.Upgrade): # Machines only - if not self._upgrade.is_compatible: - self.set_status(event=event) - return - if self._upgrade.unit_state is upgrade.UnitState.OUTDATED: - if self._upgrade.authorized: - self._upgrade.upgrade_unit( - event=event, - workload_=workload_, - tls=self._tls_certificate_saved, - exporter_config=self._cos_exporter_config(event), - ) - else: - self.set_status(event=event) - logger.debug("Waiting to upgrade") - return logger.debug( "State of reconcile " f"{self._unit_lifecycle.authorized_leader=}, " - f"{isinstance(workload_, workload.AuthenticatedWorkload)=}, " + f"{isinstance(workload_, workload.RunningWorkload)=}, " f"{workload_.container_ready=}, " + f"{self.refresh.workload_allowed_to_start=}, " f"{self._database_requires.is_relation_breaking(event)=}, " - f"{self._upgrade.in_progress=}, " + f"{self._database_requires.does_relation_exist()=}, " + f"{self.refresh.in_progress=}, " f"{self._cos_relation.is_relation_breaking(event)=}" ) + if isinstance(self.refresh, charm_refresh.Machines): + workload_.install( + unit=self.unit, + model_uuid=self.model.uuid, + snap_revision=self.refresh.pinned_snap_revision, + refresh=self.refresh, + ) # only in machine charms if self._ha_cluster: @@ -330,14 +321,14 @@ def reconcile(self, event=None) -> None: # noqa: C901 try: if self._unit_lifecycle.authorized_leader: if self._database_requires.is_relation_breaking(event): - if self._upgrade.in_progress: + if self.refresh.in_progress: logger.warning( "Modifying relations during an upgrade is not supported. The charm may be in a broken, unrecoverable state. Re-deploy the charm" ) self._database_provides.delete_all_databags() elif ( - not self._upgrade.in_progress - and isinstance(workload_, workload.AuthenticatedWorkload) + not self.refresh.in_progress + and isinstance(workload_, workload.RunningWorkload) and workload_.container_ready ): self._reconcile_service() @@ -361,33 +352,29 @@ def reconcile(self, event=None) -> None: # noqa: C901 certificate=self._tls_certificate, certificate_authority=self._tls_certificate_authority, ) - if not self._upgrade.in_progress and isinstance( - workload_, workload.AuthenticatedWorkload + if not self.refresh.in_progress and isinstance( + workload_, workload.RunningWorkload ): self._reconcile_ports(event=event) - # Empty waiting status means we're waiting for database requires relation before - # starting workload - if not workload_.status or workload_.status == ops.WaitingStatus(): - self._upgrade.unit_state = upgrade.UnitState.HEALTHY - if self._unit_lifecycle.authorized_leader: - self._upgrade.reconcile_partition() + logger.debug(f"{workload_.status=}") + if not workload_.status: + self.refresh.next_unit_allowed_to_refresh = True + elif ( + self.refresh.workload_allowed_to_start and workload_.status == ops.WaitingStatus() + ): + # During scale up, this code should not be reached before the first + # relation-created event is received on this unit since otherwise + # `charm_refresh.PeerRelationNotReady` would be raised + if self._database_requires.does_relation_exist(): + # Waiting for relation-changed event before starting workload + pass + else: + # Waiting for database requires relation; refresh can continue + self.refresh.next_unit_allowed_to_refresh = True self.set_status(event=event) except server_exceptions.Error as e: # If not for `unit=False`, another `server_exceptions.Error` could be thrown here self.set_status(event=event, unit=False) self.unit.status = e.status logger.debug(f"Set unit status to {self.unit.status}") - - def _on_resume_upgrade_action(self, event: ops.ActionEvent) -> None: - if not self._unit_lifecycle.authorized_leader: - message = f"Must run action on leader unit. (e.g. `juju run {self.app.name}/leader {upgrade.RESUME_ACTION_NAME}`)" - logger.debug(f"Resume upgrade event failed: {message}") - event.fail(message) - return - if not self._upgrade or not self._upgrade.in_progress: - message = "No upgrade in progress" - logger.debug(f"Resume upgrade event failed: {message}") - event.fail(message) - return - self._upgrade.reconcile_partition(action_event=event) diff --git a/src/charm.py b/src/charm.py index cc234f15f..b1db808c9 100755 --- a/src/charm.py +++ b/src/charm.py @@ -13,6 +13,7 @@ if is_wrong_architecture() and __name__ == "__main__": ops.main.main(WrongArchitectureWarningCharm) +import dataclasses import enum import functools import json @@ -20,24 +21,24 @@ import socket import typing +import charm_refresh import lightkube import lightkube.models.core_v1 import lightkube.models.meta_v1 import lightkube.resources.core_v1 import ops +import ops.log import tenacity from charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm import abstract_charm import kubernetes_logrotate -import kubernetes_upgrade import logrotate import relations.cos import relations.database_provides import relations.database_requires import relations.secrets import rock -import upgrade import workload logger = logging.getLogger(__name__) @@ -53,17 +54,21 @@ class _ServiceType(enum.Enum): LOAD_BALANCER = "LoadBalancer" +@dataclasses.dataclass(eq=False) +class KubernetesRouterRefresh(abstract_charm.RouterRefresh, charm_refresh.CharmSpecificKubernetes): + """MySQL Router Kubernetes refresh callbacks & configuration""" + + @trace_charm( tracing_endpoint="tracing_endpoint", extra_types=( - kubernetes_upgrade.Upgrade, logrotate.LogRotate, relations.cos.COSRelation, relations.database_provides.RelationEndpoint, relations.database_requires.RelationEndpoint, relations.tls.RelationEndpoint, rock.Rock, - workload.AuthenticatedWorkload, + workload.RunningWorkload, workload.Workload, ), ) @@ -78,6 +83,12 @@ class KubernetesRouterCharm(abstract_charm.MySQLRouterCharm): def __init__(self, *args) -> None: super().__init__(*args) + # Show logger name (module name) in logs + root_logger = logging.getLogger() + for handler in root_logger.handlers: + if isinstance(handler, ops.log.JujuLogHandler): + handler.setFormatter(logging.Formatter("{name}:{message}", style="{")) + self._namespace = self.model.name self.service_name = f"{self.app.name}-service" @@ -86,11 +97,28 @@ def __init__(self, *args) -> None: self._peer_data = relations.secrets.RelationSecrets(self, self._PEER_RELATION_NAME) self.framework.observe(self.on.install, self._on_install) - self.framework.observe(self.on.config_changed, self.reconcile) - self.framework.observe( - self.on[rock.CONTAINER_NAME].pebble_ready, self._on_workload_container_pebble_ready - ) - self.framework.observe(self.on.stop, self._on_stop) + try: + self.refresh = charm_refresh.Kubernetes( + KubernetesRouterRefresh( + workload_name="Router", + charm_name="mysql-router-k8s", + oci_resource_name="mysql-router-image", + ) + ) + except charm_refresh.UnitTearingDown: + # MySQL server charm will clean up users & router metadata when the MySQL Router app or + # unit(s) tear down + self.unit.status = ops.MaintenanceStatus("Tearing down") + self._reconcile_allowed = False + except charm_refresh.KubernetesJujuAppNotTrusted: + self._reconcile_allowed = False + except charm_refresh.PeerRelationNotReady: + self.unit.status = ops.MaintenanceStatus("Waiting for peer relation") + if self.unit.is_leader(): + self.app.status = ops.MaintenanceStatus("Waiting for peer relation") + self._reconcile_allowed = False + else: + self._reconcile_allowed = True @property def _subordinate_relation_endpoint_names(self) -> typing.Optional[typing.Iterable[str]]: @@ -104,13 +132,6 @@ def _container(self) -> rock.Rock: def _logrotate(self) -> logrotate.LogRotate: return kubernetes_logrotate.LogRotate(container_=self._container) - @property - def _upgrade(self) -> typing.Optional[kubernetes_upgrade.Upgrade]: - try: - return kubernetes_upgrade.Upgrade(self) - except upgrade.PeerRelationNotReady: - pass - @property def _status(self) -> ops.StatusBase: if self.config.get("expose-external", "false") not in [ @@ -236,7 +257,7 @@ def _reconcile_service(self) -> None: def _check_service_connectivity(self) -> bool: """Check if the service is available (connectable with a socket).""" if not self._get_service() or not isinstance( - self.get_workload(event=None), workload.AuthenticatedWorkload + self.get_workload(event=None), workload.RunningWorkload ): logger.debug("No service or unauthenticated workload") return False @@ -415,33 +436,10 @@ def get_all_k8s_node_hostnames_and_ips( def _on_install(self, _) -> None: """Open ports & patch k8s service.""" + # TODO fix this if fails because app not trusted and user runs `juju trust` if ops.JujuVersion.from_environ().supports_open_port_on_k8s: for port in (self._READ_WRITE_PORT, self._READ_ONLY_PORT, 6448, 6449): self.unit.open_port("tcp", port) - if not self.unit.is_leader(): - return - - def _on_workload_container_pebble_ready(self, _) -> None: - self.unit.set_workload_version(self.get_workload(event=None).version) - self.reconcile() - - def _on_stop(self, _) -> None: - # During the stop event, the unit could be upgrading, scaling down, or just restarting. - if self._unit_lifecycle.tearing_down_and_app_active: - # Unit is tearing down and 1+ other units are not tearing down (scaling down) - # The partition should never be greater than the highest unit number, since that will - # cause `juju refresh` to have no effect - return - unit_number = int(self.unit.name.split("/")[-1]) - # Raise partition to prevent other units from restarting if an upgrade is in progress. - # If an upgrade is not in progress, the leader unit will reset the partition to 0. - if kubernetes_upgrade.partition.get(app_name=self.app.name) < unit_number: - kubernetes_upgrade.partition.set(app_name=self.app.name, value=unit_number) - logger.debug(f"Partition set to {unit_number} during stop event") - if not self._upgrade: - logger.debug("Peer relation missing during stop event") - return - self._upgrade.unit_state = upgrade.UnitState.RESTARTING if __name__ == "__main__": diff --git a/src/container.py b/src/container.py index 8ddd61d13..e72864e66 100644 --- a/src/container.py +++ b/src/container.py @@ -8,6 +8,7 @@ import subprocess import typing +import charm_refresh import ops if typing.TYPE_CHECKING: @@ -61,6 +62,13 @@ def __init__( super().__init__(returncode=returncode, cmd=cmd, output=output, stderr=stderr) +class RefreshFailed(Exception): + """Snap failed to refresh. Previous snap revision is still installed + + Only applies to machine charm + """ + + class Container(abc.ABC): """Workload container (snap or rock)""" @@ -163,11 +171,33 @@ def update_mysql_router_exporter_service( "`key`, `certificate` and `certificate_authority` required when tls=True" ) + @staticmethod + @abc.abstractmethod + def install( + *, unit: ops.Unit, model_uuid: str, snap_revision: str, refresh: charm_refresh.Machines + ) -> None: + """Ensure snap is installed by this charm + + Only applies to machine charm + + If snap is not installed, install it + If snap is installed, check that it was installed by this charm & raise an exception otherwise + + Automatically retries if snap installation fails + """ + + @staticmethod @abc.abstractmethod - def upgrade(self, unit: ops.Unit) -> None: - """Upgrade container version + def refresh( + *, unit: ops.Unit, model_uuid: str, snap_revision: str, refresh: charm_refresh.Machines + ) -> None: + """Refresh snap Only applies to machine charm + + If snap refresh fails and previous revision is still installed, raises `RefreshFailed` + + Does not automatically retry if snap installation fails """ @abc.abstractmethod diff --git a/src/kubernetes_upgrade.py b/src/kubernetes_upgrade.py deleted file mode 100644 index 68ce881da..000000000 --- a/src/kubernetes_upgrade.py +++ /dev/null @@ -1,249 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -"""In-place upgrades on Kubernetes - -Implements specification: DA058 - In-Place Upgrades - Kubernetes v2 -(https://docs.google.com/document/d/1tLjknwHudjcHs42nzPVBNkHs98XxAOT2BXGGpP7NyEU/) -""" - -import functools -import logging -import typing - -import lightkube -import lightkube.core.exceptions -import lightkube.models.apps_v1 -import lightkube.resources.apps_v1 -import lightkube.resources.core_v1 -import ops - -import upgrade -import workload - -logger = logging.getLogger(__name__) - - -class DeployedWithoutTrust(Exception): - """Deployed without `juju deploy --trust` or `juju trust` - - Needed to access Kubernetes StatefulSet - """ - - def __init__(self, *, app_name: str): - super().__init__( - f"Run `juju trust {app_name} --scope=cluster` and `juju resolve` for each unit (or remove & re-deploy {app_name} with `--trust`)" - ) - - -class _Partition: - """StatefulSet partition getter/setter""" - - # Note: I realize this isn't very Pythonic (it'd be nicer to use a property). Because of how - # ops is structured, we don't have access to the app name when we initialize this class. We - # need to only initialize this class once so that there is a single cache. Therefore, the app - # name needs to be passed as argument to the methods (instead of as an argument to __init__)— - # so we can't use a property. - - def __init__(self): - # Cache lightkube API call for duration of charm execution - self._cache: dict[str, int] = {} - - def get(self, *, app_name: str) -> int: - return self._cache.setdefault( - app_name, - lightkube.Client() - .get(res=lightkube.resources.apps_v1.StatefulSet, name=app_name) - .spec.updateStrategy.rollingUpdate.partition, - ) - - def set(self, *, app_name: str, value: int) -> None: - lightkube.Client().patch( - res=lightkube.resources.apps_v1.StatefulSet, - name=app_name, - obj={"spec": {"updateStrategy": {"rollingUpdate": {"partition": value}}}}, - ) - self._cache[app_name] = value - - -class Upgrade(upgrade.Upgrade): - """In-place upgrades on Kubernetes""" - - def __init__(self, charm_: ops.CharmBase, *args, **kwargs): - try: - partition.get(app_name=charm_.app.name) - except lightkube.core.exceptions.ApiError as e: - if e.status.code == 403: - raise DeployedWithoutTrust(app_name=charm_.app.name) - raise - super().__init__(charm_, *args, **kwargs) - - def _get_unit_healthy_status( - self, *, workload_status: typing.Optional[ops.StatusBase] - ) -> typing.Optional[ops.StatusBase]: - if ( - self._unit_workload_container_versions[self._unit.name] - == self._app_workload_container_version - ): - if isinstance(workload_status, ops.WaitingStatus): - return ops.WaitingStatus( - f'Router {self._current_versions["workload"]}; Charmed operator {self._current_versions["charm"]}' - ) - return ops.ActiveStatus( - f'Router {self._current_versions["workload"]} running; Charmed operator {self._current_versions["charm"]}' - ) - if isinstance(workload_status, ops.WaitingStatus): - return ops.WaitingStatus( - f'Router {self._current_versions["workload"]}; Charmed operator {self._current_versions["charm"]}' - ) - # During a rollback, non-upgraded units will restart - # (Juju bug: https://bugs.launchpad.net/juju/+bug/2036246) - # To explain this behavior to the user, we include "(restart pending)" in the status - # message. For non-upgraded units: the charm and workload version will be the same, but - # since the Kubernetes controller revision hash is different, the unit (pod) will restart - # during rollback. - return ops.ActiveStatus( - f'Router {self._current_versions["workload"]} running (restart pending); Charmed operator {self._current_versions["charm"]}' - ) - - @property - def upgrade_resumed(self) -> bool: - return self._partition < upgrade.unit_number(self._sorted_units[0]) - - @property - def _partition(self) -> int: - """Specifies which units should upgrade - - Unit numbers >= partition should upgrade - Unit numbers < partition should not upgrade - - https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#partitions - - For Kubernetes, unit numbers are guaranteed to be sequential - """ - return partition.get(app_name=self._app_name) - - @_partition.setter - def _partition(self, value: int) -> None: - partition.set(app_name=self._app_name, value=value) - - @functools.cached_property # Cache lightkube API call for duration of charm execution - def _unit_workload_container_versions(self) -> dict[str, str]: - """{Unit name: Kubernetes controller revision hash} - - Even if the workload container version is the same, the workload will restart if the - controller revision hash changes. (Juju bug: https://bugs.launchpad.net/juju/+bug/2036246). - - Therefore, we must use the revision hash instead of the workload container version. (To - satisfy the requirement that if and only if this version changes, the workload will - restart.) - """ - pods = lightkube.Client().list( - res=lightkube.resources.core_v1.Pod, labels={"app.kubernetes.io/name": self._app_name} - ) - - def get_unit_name(pod_name: str) -> str: - *app_name, unit_number = pod_name.split("-") - return f'{"-".join(app_name)}/{unit_number}' - - return { - get_unit_name(pod.metadata.name): pod.metadata.labels["controller-revision-hash"] - for pod in pods - } - - @functools.cached_property # Cache lightkube API call for duration of charm execution - def _app_workload_container_version(self) -> str: - """App's Kubernetes controller revision hash""" - stateful_set = lightkube.Client().get( - res=lightkube.resources.apps_v1.StatefulSet, name=self._app_name - ) - return stateful_set.status.updateRevision - - def reconcile_partition(self, *, action_event: ops.ActionEvent = None) -> None: # noqa: C901 - """If ready, lower partition to upgrade next unit. - - If upgrade is not in progress, set partition to 0. (If a unit receives a stop event, it may - raise the partition even if an upgrade is not in progress.) - - Automatically upgrades next unit if all upgraded units are healthy—except if only one unit - has upgraded (need manual user confirmation [via Juju action] to upgrade next unit) - - Handle Juju action to: - - confirm first upgraded unit is healthy and resume upgrade - - force upgrade of next unit if 1 or more upgraded units are unhealthy - """ - force = bool(action_event and action_event.params["force"] is True) - - units = self._sorted_units - - def determine_partition() -> int: - if not self.in_progress: - return 0 - logger.debug(f"{self._peer_relation.data=}") - for upgrade_order_index, unit in enumerate(units): - # Note: upgrade_order_index != unit number - state = self._peer_relation.data[unit].get("state") - if state: - state = upgrade.UnitState(state) - if ( - not force and state is not upgrade.UnitState.HEALTHY - ) or self._unit_workload_container_versions[ - unit.name - ] != self._app_workload_container_version: - if not action_event and upgrade_order_index == 1: - # User confirmation needed to resume upgrade (i.e. upgrade second unit) - return upgrade.unit_number(units[0]) - return upgrade.unit_number(unit) - return 0 - - partition_ = determine_partition() - logger.debug(f"{self._partition=}, {partition_=}") - # Only lower the partition—do not raise it. - # If this method is called during the action event and then called during another event a - # few seconds later, `determine_partition()` could return a lower number during the action - # and then a higher number a few seconds later. - # This can cause the unit to hang. - # Example: If partition is lowered to 1, unit 1 begins to upgrade, and partition is set to - # 2 right away, the unit/Juju agent will hang - # Details: https://chat.charmhub.io/charmhub/pl/on8rd538ufn4idgod139skkbfr - # This does not address the situation where another unit > 1 restarts and sets the - # partition during the `stop` event, but that is unlikely to occur in the small time window - # that causes the unit to hang. - if partition_ < self._partition: - self._partition = partition_ - logger.debug( - f"Lowered partition to {partition_} {action_event=} {force=} {self.in_progress=}" - ) - if action_event: - assert len(units) >= 2 - if self._partition > upgrade.unit_number(units[1]): - message = "Highest number unit is unhealthy. Upgrade will not resume." - logger.debug(f"Resume upgrade event failed: {message}") - action_event.fail(message) - return - if force: - logger.warning(f"Resume upgrade action ran with {force=}") - # If a unit was unhealthy and the upgrade was forced, only the next unit will - # upgrade. As long as 1 or more units are unhealthy, the upgrade will need to be - # forced for each unit. - - # Include "Attempting to" because (on Kubernetes) we only control the partition, - # not which units upgrade. Kubernetes may not upgrade a unit even if the partition - # allows it (e.g. if the charm container of a higher unit is not ready). This is - # also applicable `if not force`, but is unlikely to happen since all units are - # healthy `if not force`. - message = f"Attempting to upgrade unit {self._partition}" - else: - message = f"Upgrade resumed. Unit {self._partition} is upgrading next" - action_event.set_results({"result": message}) - logger.debug(f"Resume upgrade event succeeded: {message}") - - @property - def authorized(self) -> bool: - raise Exception("Not supported on Kubernetes") - - def upgrade_unit(self, *, event, workload_: workload.Workload, tls: bool) -> None: - raise Exception("Not supported on Kubernetes") - - -partition = _Partition() diff --git a/src/machine_upgrade.py b/src/machine_upgrade.py deleted file mode 100644 index 175f98f6f..000000000 --- a/src/machine_upgrade.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Only implemented in machine charm""" - - -class Upgrade: - """Only implemented in machine charm""" diff --git a/src/relations/cos.py b/src/relations/cos.py index 1cfe01c07..1f509607f 100644 --- a/src/relations/cos.py +++ b/src/relations/cos.py @@ -73,15 +73,6 @@ def __init__(self, charm_: "abstract_charm.MySQLRouterCharm", container_: contai self._charm = charm_ self._container = container_ - charm_.framework.observe( - charm_.on[self._METRICS_RELATION_NAME].relation_created, - charm_.reconcile, - ) - charm_.framework.observe( - charm_.on[self._METRICS_RELATION_NAME].relation_broken, - charm_.reconcile, - ) - self._secrets = relations.secrets.RelationSecrets( charm_, self._PEER_RELATION_NAME, diff --git a/src/relations/database_provides.py b/src/relations/database_provides.py index ef548eb98..a4147da00 100644 --- a/src/relations/database_provides.py +++ b/src/relations/database_provides.py @@ -183,9 +183,7 @@ class RelationEndpoint: def __init__(self, charm_: "abstract_charm.MySQLRouterCharm") -> None: self._interface = data_interfaces.DatabaseProvides(charm_, relation_name=self._NAME) - charm_.framework.observe(charm_.on[self._NAME].relation_created, charm_.reconcile) charm_.framework.observe(self._interface.on.database_requested, charm_.reconcile) - charm_.framework.observe(charm_.on[self._NAME].relation_broken, charm_.reconcile) @property # TODO python3.10 min version: Use `list` instead of `typing.List` diff --git a/src/relations/database_requires.py b/src/relations/database_requires.py index bdb23da37..1e1b33791 100644 --- a/src/relations/database_requires.py +++ b/src/relations/database_requires.py @@ -6,6 +6,7 @@ import logging import typing +import charm_ as charm import charms.data_platform_libs.v0.data_interfaces as data_interfaces import ops @@ -109,10 +110,8 @@ def __init__(self, charm_: "abstract_charm.MySQLRouterCharm") -> None: database_name="mysql_innodb_cluster_metadata", extra_user_roles="mysqlrouter", ) - charm_.framework.observe(charm_.on[self._NAME].relation_created, charm_.reconcile) charm_.framework.observe(self._interface.on.database_created, charm_.reconcile) charm_.framework.observe(self._interface.on.endpoints_changed, charm_.reconcile) - charm_.framework.observe(charm_.on[self._NAME].relation_broken, charm_.reconcile) def get_connection_info(self, *, event) -> typing.Optional[CompleteConnectionInformation]: """Information for connection to MySQL cluster""" @@ -137,3 +136,11 @@ def get_status(self, event) -> typing.Optional[ops.StatusBase]: CompleteConnectionInformation(interface=self._interface, event=event) except (_MissingRelation, remote_databag.IncompleteDatabag) as exception: return exception.status + + def does_relation_exist(self) -> bool: + """Whether a relation exists + + From testing: during scale up, this should return `True` as soon as this unit receives the + first relation-created event on any endpoint + """ + return charm.Endpoint(self._NAME).relation is not None diff --git a/src/rock.py b/src/rock.py index 5ffc4e52b..5cc561540 100644 --- a/src/rock.py +++ b/src/rock.py @@ -196,7 +196,12 @@ def update_mysql_router_exporter_service( else: self._container.stop(self._EXPORTER_SERVICE_NAME) - def upgrade(self, unit: ops.Unit) -> None: + @staticmethod + def install(*_, **__) -> None: + raise Exception("Not supported on Kubernetes") + + @staticmethod + def refresh(*_, **__) -> None: raise Exception("Not supported on Kubernetes") def update_logrotate_executor_service(self, *, enabled: bool) -> None: diff --git a/src/upgrade.py b/src/upgrade.py deleted file mode 100644 index 0535c89f2..000000000 --- a/src/upgrade.py +++ /dev/null @@ -1,230 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -"""In-place upgrades - -Based off specification: DA058 - In-Place Upgrades - Kubernetes v2 -(https://docs.google.com/document/d/1tLjknwHudjcHs42nzPVBNkHs98XxAOT2BXGGpP7NyEU/) -""" - -import abc -import copy -import enum -import json -import logging -import pathlib -import typing - -import ops -import poetry.core.constraints.version as poetry_version - -import workload - -logger = logging.getLogger(__name__) - -PEER_RELATION_ENDPOINT_NAME = "upgrade-version-a" -RESUME_ACTION_NAME = "resume-upgrade" - - -def unit_number(unit_: ops.Unit) -> int: - """Get unit number""" - return int(unit_.name.split("/")[-1]) - - -class PeerRelationNotReady(Exception): - """Upgrade peer relation not available (to this unit)""" - - -class UnitState(str, enum.Enum): - """Unit upgrade state""" - - HEALTHY = "healthy" - RESTARTING = "restarting" # Kubernetes only - UPGRADING = "upgrading" # Machines only - OUTDATED = "outdated" # Machines only - - -class Upgrade(abc.ABC): - """In-place upgrades""" - - def __init__(self, charm_: ops.CharmBase) -> None: - relations = charm_.model.relations[PEER_RELATION_ENDPOINT_NAME] - if not relations: - raise PeerRelationNotReady - assert len(relations) == 1 - self._peer_relation = relations[0] - self._unit: ops.Unit = charm_.unit - self._unit_databag = self._peer_relation.data[self._unit] - self._app_databag = self._peer_relation.data[charm_.app] - self._app_name = charm_.app.name - self._current_versions = {} # For this unit - for version, file_name in { - "charm": "charm_version", - "workload": "workload_version", - }.items(): - self._current_versions[version] = pathlib.Path(file_name).read_text().strip() - - @property - def unit_state(self) -> typing.Optional[UnitState]: - """Unit upgrade state""" - if state := self._unit_databag.get("state"): - return UnitState(state) - - @unit_state.setter - def unit_state(self, value: UnitState) -> None: - self._unit_databag["state"] = value.value - - @property - def is_compatible(self) -> bool: - """Whether upgrade is supported from previous versions""" - assert self.versions_set - previous_version_strs: typing.Dict[str, str] = json.loads(self._app_databag["versions"]) - # TODO charm versioning: remove `.split("+")` (which removes git hash before comparing) - previous_version_strs["charm"] = previous_version_strs["charm"].split("+")[0] - previous_versions: typing.Dict[str, poetry_version.Version] = { - key: poetry_version.Version.parse(value) - for key, value in previous_version_strs.items() - } - current_version_strs = copy.copy(self._current_versions) - current_version_strs["charm"] = current_version_strs["charm"].split("+")[0] - current_versions = { - key: poetry_version.Version.parse(value) for key, value in current_version_strs.items() - } - try: - if ( - previous_versions["charm"] > current_versions["charm"] - or previous_versions["charm"].major != current_versions["charm"].major - ): - logger.debug( - f'{previous_versions["charm"]=} incompatible with {current_versions["charm"]=}' - ) - return False - if ( - previous_versions["workload"] > current_versions["workload"] - or previous_versions["workload"].major != current_versions["workload"].major - or previous_versions["workload"].minor != current_versions["workload"].minor - ): - logger.debug( - f'{previous_versions["workload"]=} incompatible with {current_versions["workload"]=}' - ) - return False - logger.debug( - f"Versions before upgrade compatible with versions after upgrade {previous_version_strs=} {self._current_versions=}" - ) - return True - except KeyError as exception: - logger.debug(f"Version missing from {previous_versions=}", exc_info=exception) - return False - - @property - def in_progress(self) -> bool: - logger.debug( - f"{self._app_workload_container_version=} {self._unit_workload_container_versions=}" - ) - return any( - version != self._app_workload_container_version - for version in self._unit_workload_container_versions.values() - ) - - @property - def _sorted_units(self) -> typing.List[ops.Unit]: - """Units sorted from highest to lowest unit number""" - return sorted((self._unit, *self._peer_relation.units), key=unit_number, reverse=True) - - @abc.abstractmethod - def _get_unit_healthy_status( - self, *, workload_status: typing.Optional[ops.StatusBase] - ) -> ops.StatusBase: - """Status shown during upgrade if unit is healthy""" - - def get_unit_juju_status( - self, *, workload_status: typing.Optional[ops.StatusBase] - ) -> typing.Optional[ops.StatusBase]: - if self.in_progress: - return self._get_unit_healthy_status(workload_status=workload_status) - - @property - def app_status(self) -> typing.Optional[ops.StatusBase]: - if not self.in_progress: - return - if not self.upgrade_resumed: - # User confirmation needed to resume upgrade (i.e. upgrade second unit) - # Statuses over 120 characters are truncated in `juju status` as of juju 3.1.6 and - # 2.9.45 - return ops.BlockedStatus( - f"Upgrading. Verify highest unit is healthy & run `{RESUME_ACTION_NAME}` action. To rollback, `juju refresh` to last revision" - ) - return ops.MaintenanceStatus( - "Upgrading. To rollback, `juju refresh` to the previous revision" - ) - - @property - def versions_set(self) -> bool: - """Whether versions have been saved in app databag - - Should only be `False` during first charm install - - If a user upgrades from a charm that does not set versions, this charm will get stuck. - """ - return self._app_databag.get("versions") is not None - - def set_versions_in_app_databag(self) -> None: - """Save current versions in app databag - - Used after next upgrade to check compatibility (i.e. whether that upgrade should be - allowed) - """ - assert not self.in_progress - logger.debug(f"Setting {self._current_versions=} in upgrade peer relation app databag") - self._app_databag["versions"] = json.dumps(self._current_versions) - logger.debug(f"Set {self._current_versions=} in upgrade peer relation app databag") - - @property - @abc.abstractmethod - def upgrade_resumed(self) -> bool: - """Whether user has resumed upgrade with Juju action""" - - @property - @abc.abstractmethod - def _unit_workload_container_versions(self) -> typing.Dict[str, str]: - """{Unit name: unique identifier for unit's workload container version} - - If and only if this version changes, the workload will restart (during upgrade or - rollback). - - On Kubernetes, the workload & charm are upgraded together - On machines, the charm is upgraded before the workload - - This identifier should be comparable to `_app_workload_container_version` to determine if - the unit & app are the same workload container version. - """ - - @property - @abc.abstractmethod - def _app_workload_container_version(self) -> str: - """Unique identifier for the app's workload container version - - This should match the workload version in the current Juju app charm version. - - This identifier should be comparable to `_unit_workload_container_versions` to determine if - the app & unit are the same workload container version. - """ - - @abc.abstractmethod - def reconcile_partition(self, *, action_event: ops.ActionEvent = None) -> None: - """If ready, allow next unit to upgrade.""" - - @property - @abc.abstractmethod - def authorized(self) -> bool: - """Whether this unit is authorized to upgrade - - Only applies to machine charm - """ - - @abc.abstractmethod - def upgrade_unit(self, *, event, workload_: workload.Workload, tls: bool) -> None: - """Upgrade this unit. - - Only applies to machine charm - """ diff --git a/src/workload.py b/src/workload.py index 2643641e1..d2bc01c98 100644 --- a/src/workload.py +++ b/src/workload.py @@ -11,6 +11,7 @@ import string import typing +import charm_refresh import ops import requests import tenacity @@ -67,25 +68,45 @@ def container_ready(self) -> bool: """ return self._container.ready - @property - def version(self) -> str: - """MySQL Router version""" - version = self._container.run_mysql_router(["--version"]) - for component in version.split(): - if component.startswith("8"): - return component - return "" - - def upgrade( - self, *, event, unit: ops.Unit, tls: bool, exporter_config: "relations.cos.ExporterConfig" + def install( + self, + *, + unit: ops.Unit, + model_uuid: str, + snap_revision: str, + refresh: charm_refresh.Machines, ) -> None: - """Upgrade MySQL Router. + """Ensure snap is installed by this charm Only applies to machine charm + + If snap is not installed, install it + If snap is installed, check that it was installed by this charm & raise an exception otherwise + + Automatically retries if snap installation fails """ - logger.debug("Upgrading MySQL Router") - self._container.upgrade(unit=unit) - logger.debug("Upgraded MySQL Router") + self._container.install( + unit=unit, model_uuid=model_uuid, snap_revision=snap_revision, refresh=refresh + ) + + def refresh( + self, + *, + event, + unit: ops.Unit, + model_uuid: str, + snap_revision: str, + refresh: charm_refresh.Machines, + tls: bool, + exporter_config: "relations.cos.ExporterConfig", + ) -> None: + """Refresh MySQL Router + + Only applies to machine charm + """ + self._container.refresh( + unit=unit, model_uuid=model_uuid, snap_revision=snap_revision, refresh=refresh + ) @property def _tls_config_file_data(self) -> str: @@ -186,7 +207,7 @@ def status(self) -> typing.Optional[ops.StatusBase]: return ops.WaitingStatus() -class AuthenticatedWorkload(Workload): +class RunningWorkload(Workload): """Workload with connection to MySQL cluster""" def __init__( @@ -420,8 +441,16 @@ def status(self) -> typing.Optional[ops.StatusBase]: "Router was manually removed from MySQL ClusterSet. Remove & re-deploy unit" ) - def upgrade( - self, *, event, unit: ops.Unit, tls: bool, exporter_config: "relations.cos.ExporterConfig" + def refresh( + self, + *, + event, + unit: ops.Unit, + model_uuid: str, + snap_revision: str, + refresh: charm_refresh.Machines, + tls: bool, + exporter_config: "relations.cos.ExporterConfig", ) -> None: enabled = self._container.mysql_router_service_enabled exporter_enabled = self._container.mysql_router_exporter_service_enabled @@ -430,12 +459,27 @@ def upgrade( if enabled: logger.debug("Disabling MySQL Router service before upgrade") self._disable_router() - super().upgrade(event=event, unit=unit, tls=tls, exporter_config=exporter_config) - if enabled: - logger.debug("Re-enabling MySQL Router service after upgrade") - self._enable_router(event=event, tls=tls, unit_name=unit.name) - if exporter_enabled: - self._enable_exporter(tls=tls, exporter_config=exporter_config) + try: + super().refresh( + event=event, + unit=unit, + model_uuid=model_uuid, + snap_revision=snap_revision, + refresh=refresh, + tls=tls, + exporter_config=exporter_config, + ) + except container.RefreshFailed: + message = "Re-enabling MySQL Router service after failed snap refresh" + raise + else: + message = "Re-enabling MySQL Router service after refresh" + finally: + if enabled: + logger.debug(message) + self._enable_router(event=event, tls=tls, unit_name=unit.name) + if exporter_enabled: + self._enable_exporter(tls=tls, exporter_config=exporter_config) def _wait_until_http_server_authenticates(self) -> None: """Wait until active connection with router HTTP server using monitoring credentials.""" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index b65ed601a..b27f33206 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -31,7 +31,7 @@ def patch(monkeypatch): "charm.KubernetesRouterCharm.wait_until_mysql_router_ready", lambda *args, **kwargs: None, ) - monkeypatch.setattr("workload.AuthenticatedWorkload._router_username", "") + monkeypatch.setattr("workload.RunningWorkload._router_username", "") monkeypatch.setattr("mysql_shell.Shell._run_code", lambda *args, **kwargs: None) monkeypatch.setattr( "mysql_shell.Shell.get_mysql_router_user_for_unit", lambda *args, **kwargs: None diff --git a/tests/unit/test_workload.py b/tests/unit/test_workload.py index 64ef369be..13296d6e4 100644 --- a/tests/unit/test_workload.py +++ b/tests/unit/test_workload.py @@ -185,4 +185,4 @@ ], ) def test_parse_username_from_config(config_file_text, username): - assert workload.AuthenticatedWorkload._parse_username_from_config(config_file_text) == username + assert workload.RunningWorkload._parse_username_from_config(config_file_text) == username diff --git a/workload_version b/workload_version deleted file mode 100644 index d5c638838..000000000 --- a/workload_version +++ /dev/null @@ -1 +0,0 @@ -8.0.41