├── .github └── workflows │ └── pypi-publish.yaml ├── .gitignore ├── LICENSE ├── README.md ├── pyhelm3 ├── __init__.py ├── client.py ├── command.py ├── errors.py └── models.py ├── pyproject.toml ├── setup.cfg └── setup.py /.github/workflows/pypi-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | push: 4 | tags: 5 | - "**" 6 | 7 | jobs: 8 | publish_to_pypi: 9 | runs-on: ubuntu-latest 10 | environment: 11 | name: pypi 12 | url: https://pypi.org/p/pyhelm3 13 | permissions: 14 | id-token: write 15 | steps: 16 | - name: Check out the repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: "3.x" 23 | 24 | - name: Install pypa/build 25 | run: python3 -m pip install build --user 26 | 27 | - name: Build a binary wheel and a source tarball 28 | run: python3 -m build --sdist --wheel --outdir dist/ 29 | 30 | - name: Publish to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.egg-info 3 | .python-version -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyhelm3 2 | 3 | Python library for managing Helm releases using Helm 3 (i.e. Tiller-less Helm). 4 | 5 | ## Installation 6 | 7 | `pyhelm3` can be installed from PyPI: 8 | 9 | ```sh 10 | pip install pyhelm3 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```python 16 | from pyhelm3 import Client 17 | 18 | 19 | # This will use the Kubernetes configuration from the environment 20 | client = Client() 21 | # Specify the kubeconfig file to use 22 | client = Client(kubeconfig = "/path/to/kubeconfig") 23 | # Specify the kubecontext to use 24 | client = Client(kubecontext = "kubecontext") 25 | # Specify a custom Helm executable (by default, we expect 'helm' to be on the PATH) 26 | client = Client(executable = "/path/to/helm") 27 | 28 | 29 | # List the deployed releases 30 | releases = await client.list_releases(all = True, all_namespaces = True) 31 | for release in releases: 32 | revision = await release.current_revision() 33 | print(release.name, release.namespace, revision.revision, str(revision.status)) 34 | 35 | 36 | # Get the current revision for an existing release 37 | revision = await client.get_current_revision("cert-manager", namespace = "cert-manager") 38 | chart_metadata = await revision.chart_metadata() 39 | print( 40 | revision.release.name, 41 | revision.release.namespace, 42 | revision.revision, 43 | str(revision.status), 44 | chart_metadata.name, 45 | chart_metadata.version 46 | ) 47 | 48 | 49 | # Fetch a chart 50 | chart = await client.get_chart( 51 | "cert-manager", 52 | repo = "https://charts.jetstack.io", 53 | version = "v1.8.x" 54 | ) 55 | print(chart.metadata.name, chart.metadata.version) 56 | print(await chart.readme()) 57 | 58 | 59 | # Install or upgrade a release 60 | revision = await client.install_or_upgrade_release( 61 | "cert-manager", 62 | chart, 63 | { "installCRDs": True }, 64 | atomic = True, 65 | wait = True 66 | ) 67 | print( 68 | revision.release.name, 69 | revision.release.namespace, 70 | revision.revision, 71 | str(revision.status) 72 | ) 73 | 74 | 75 | # Uninstall a release 76 | # Via the revision 77 | revision = await client.get_current_revision("cert-manager", namespace = "cert-manager") 78 | await revision.release.uninstall(wait = True) 79 | #  Or directly by name 80 | await client.uninstall_release("cert-manager", namespace = "cert-manager", wait = True) 81 | ``` 82 | -------------------------------------------------------------------------------- /pyhelm3/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import * 2 | from .command import * 3 | from .errors import * 4 | from .models import * 5 | -------------------------------------------------------------------------------- /pyhelm3/client.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import functools 3 | import pathlib 4 | import shutil 5 | import typing as t 6 | 7 | import yaml 8 | 9 | from .command import Command, SafeLoader 10 | from .errors import ReleaseNotFoundError 11 | from .models import Chart, Release, ReleaseRevision, ReleaseRevisionStatus 12 | 13 | 14 | def mergeconcat( 15 | defaults: t.Dict[t.Any, t.Any], 16 | *overrides: t.Dict[t.Any, t.Any] 17 | ) -> t.Dict[t.Any, t.Any]: 18 | """ 19 | Deep-merge two or more dictionaries together. Lists are concatenated. 20 | """ 21 | def mergeconcat2(defaults, overrides): 22 | if isinstance(defaults, dict) and isinstance(overrides, dict): 23 | merged = dict(defaults) 24 | for key, value in overrides.items(): 25 | if key in defaults: 26 | merged[key] = mergeconcat2(defaults[key], value) 27 | else: 28 | merged[key] = value 29 | return merged 30 | elif isinstance(defaults, (list, tuple)) and isinstance(overrides, (list, tuple)): 31 | merged = list(defaults) 32 | merged.extend(overrides) 33 | return merged 34 | else: 35 | return overrides if overrides is not None else defaults 36 | return functools.reduce(mergeconcat2, overrides, defaults) 37 | 38 | 39 | #: Bound type var for forward references 40 | ClientType = t.TypeVar("ClientType", bound = "Client") 41 | 42 | 43 | class Client: 44 | """ 45 | Entrypoint for interactions with Helm. 46 | """ 47 | def __init__( 48 | self, 49 | command: t.Optional[Command] = None, 50 | *, 51 | default_timeout: t.Union[int, str] = "5m", 52 | executable: str = "helm", 53 | history_max_revisions: int = 10, 54 | insecure_skip_tls_verify: bool = False, 55 | kubeconfig: t.Optional[pathlib.Path] = None, 56 | kubecontext: t.Optional[str] = None, 57 | kubeapiserver: t.Optional[str] = None, 58 | kubetoken: t.Optional[str] = None, 59 | unpack_directory: t.Optional[str] = None 60 | ): 61 | self._command = command or Command( 62 | default_timeout = default_timeout, 63 | executable = executable, 64 | history_max_revisions = history_max_revisions, 65 | insecure_skip_tls_verify = insecure_skip_tls_verify, 66 | kubeconfig = kubeconfig, 67 | kubecontext = kubecontext, 68 | kubeapiserver = kubeapiserver, 69 | kubetoken = kubetoken, 70 | unpack_directory = unpack_directory 71 | ) 72 | 73 | async def get_chart( 74 | self, 75 | chart_ref: t.Union[pathlib.Path, str], 76 | *, 77 | devel: bool = False, 78 | repo: t.Optional[str] = None, 79 | version: t.Optional[str] = None 80 | ) -> Chart: 81 | """ 82 | Returns the resolved chart for the given ref, repo and version. 83 | """ 84 | return Chart( 85 | self._command, 86 | ref = chart_ref, 87 | repo = repo, 88 | # Load the metadata for the specified args 89 | metadata = await self._command.show_chart( 90 | chart_ref, 91 | devel = devel, 92 | repo = repo, 93 | version = version 94 | ) 95 | ) 96 | 97 | @contextlib.asynccontextmanager 98 | async def pull_chart( 99 | self, 100 | chart_ref: t.Union[pathlib.Path, str], 101 | *, 102 | devel: bool = False, 103 | repo: t.Optional[str] = None, 104 | version: t.Optional[str] = None 105 | ) -> t.AsyncIterator[pathlib.Path]: 106 | """ 107 | Context manager that pulls the specified chart and yields a chart object 108 | whose ref is the unpacked chart directory. 109 | 110 | Ensures that the directory is cleaned up when the context manager exits. 111 | """ 112 | path = await self._command.pull( 113 | chart_ref, 114 | devel = devel, 115 | repo = repo, 116 | version = version 117 | ) 118 | try: 119 | # The path from pull is the managed directory containing the archive and unpacked chart 120 | # We want the actual chart directory 121 | chart_yaml = next(path.glob("**/Chart.yaml")) 122 | chart_directory = chart_yaml.parent 123 | # To save the overhead of another Helm command invocation, just read the Chart.yaml 124 | with chart_yaml.open() as fh: 125 | metadata = yaml.load(fh, Loader = SafeLoader) 126 | # Yield the chart object 127 | yield Chart(self._command, ref = chart_directory, metadata = metadata) 128 | finally: 129 | if path.is_dir(): 130 | shutil.rmtree(path) 131 | 132 | async def template_resources( 133 | self, 134 | chart: Chart, 135 | release_name: str, 136 | *values: t.Dict[str, t.Any], 137 | include_crds: bool = False, 138 | is_upgrade: bool = False, 139 | namespace: t.Optional[str] = None, 140 | no_hooks: bool = False, 141 | ) -> t.Iterable[t.Dict[str, t.Any]]: 142 | """ 143 | Renders the templates from the given chart with the given values and returns 144 | the resources that would be produced. 145 | """ 146 | return await self._command.template( 147 | release_name, 148 | chart.ref, 149 | mergeconcat(*values) if values else None, 150 | include_crds = include_crds, 151 | is_upgrade = is_upgrade, 152 | namespace = namespace, 153 | no_hooks = no_hooks, 154 | repo = chart.repo, 155 | version = chart.metadata.version 156 | ) 157 | 158 | async def list_releases( 159 | self, 160 | *, 161 | all: bool = False, 162 | all_namespaces: bool = False, 163 | include_deployed: bool = True, 164 | include_failed: bool = False, 165 | include_pending: bool = False, 166 | include_superseded: bool = False, 167 | include_uninstalled: bool = False, 168 | include_uninstalling: bool = False, 169 | max_releases: int = 256, 170 | namespace: t.Optional[str] = None, 171 | sort_by_date: bool = False, 172 | sort_reversed: bool = False 173 | ) -> t.Iterable[Release]: 174 | """ 175 | Returns an iterable of the deployed releases. 176 | """ 177 | return ( 178 | Release( 179 | self._command, 180 | name = release["name"], 181 | namespace = release["namespace"], 182 | ) 183 | for release in await self._command.list( 184 | all = all, 185 | all_namespaces = all_namespaces, 186 | include_deployed = include_deployed, 187 | include_failed = include_failed, 188 | include_pending = include_pending, 189 | include_superseded = include_superseded, 190 | include_uninstalled = include_uninstalled, 191 | include_uninstalling = include_uninstalling, 192 | max_releases = max_releases, 193 | namespace = namespace, 194 | sort_by_date = sort_by_date, 195 | sort_reversed = sort_reversed 196 | ) 197 | ) 198 | 199 | async def get_current_revision( 200 | self, 201 | release_name: str, 202 | *, 203 | namespace: t.Optional[str] = None 204 | ) -> ReleaseRevision: 205 | """ 206 | Returns the current revision of the named release. 207 | """ 208 | return ReleaseRevision._from_status( 209 | await self._command.status( 210 | release_name, 211 | namespace = namespace 212 | ), 213 | self._command 214 | ) 215 | 216 | async def install_or_upgrade_release( 217 | self, 218 | release_name: str, 219 | chart: Chart, 220 | *values: t.Dict[str, t.Any], 221 | atomic: bool = False, 222 | cleanup_on_fail: bool = False, 223 | create_namespace: bool = True, 224 | description: t.Optional[str] = None, 225 | dry_run: bool = False, 226 | force: bool = False, 227 | namespace: t.Optional[str] = None, 228 | no_hooks: bool = False, 229 | reset_values: bool = False, 230 | reuse_values: bool = False, 231 | skip_crds: bool = False, 232 | timeout: t.Union[int, str, None] = None, 233 | wait: bool = False 234 | ) -> ReleaseRevision: 235 | """ 236 | Install or upgrade the named release using the given chart and values and return 237 | the new revision. 238 | """ 239 | return ReleaseRevision._from_status( 240 | await self._command.install_or_upgrade( 241 | release_name, 242 | chart.ref, 243 | mergeconcat(*values) if values else None, 244 | atomic = atomic, 245 | cleanup_on_fail = cleanup_on_fail, 246 | create_namespace = create_namespace, 247 | description = description, 248 | dry_run = dry_run, 249 | force = force, 250 | namespace = namespace, 251 | no_hooks = no_hooks, 252 | repo = chart.repo, 253 | reset_values = reset_values, 254 | reuse_values = reuse_values, 255 | skip_crds = skip_crds, 256 | timeout = timeout, 257 | version = chart.metadata.version, 258 | wait = wait 259 | ), 260 | self._command 261 | ) 262 | 263 | async def get_proceedable_revision( 264 | self, 265 | release_name: str, 266 | *, 267 | namespace: t.Optional[str] = None, 268 | timeout: t.Union[int, str, None] = None 269 | ) -> ReleaseRevision: 270 | """ 271 | Returns a proceedable revision for the named release by rolling back or deleting 272 | as appropriate where the release has been left in a pending state. 273 | """ 274 | try: 275 | current_revision = await self.get_current_revision( 276 | release_name, 277 | namespace = namespace 278 | ) 279 | except ReleaseNotFoundError: 280 | # This condition is an easy one ;-) 281 | return None 282 | else: 283 | if current_revision.status in { 284 | # If the release is stuck in pending-install, there is nothing to rollback to 285 | # Instead, we have to uninstall the release and try again 286 | ReleaseRevisionStatus.PENDING_INSTALL, 287 | # If the release is stuck in uninstalling, we need to complete the uninstall 288 | ReleaseRevisionStatus.UNINSTALLING, 289 | }: 290 | await current_revision.release.uninstall(timeout = timeout, wait = True) 291 | return None 292 | elif current_revision.status in { 293 | # If the release is stuck in pending-upgrade, we need to rollback to the previous 294 | # revision before trying the upgrade again 295 | ReleaseRevisionStatus.PENDING_UPGRADE, 296 | # For a release stuck in pending-rollback, we need to complete the rollback 297 | ReleaseRevisionStatus.PENDING_ROLLBACK, 298 | }: 299 | return await current_revision.release.rollback( 300 | cleanup_on_fail = True, 301 | timeout = timeout, 302 | wait = True 303 | ) 304 | else: 305 | # All other statuses are proceedable 306 | return current_revision 307 | 308 | async def should_install_or_upgrade_release( 309 | self, 310 | current_revision: t.Optional[ReleaseRevision], 311 | chart: Chart, 312 | *values: t.Dict[str, t.Any] 313 | ) -> bool: 314 | """ 315 | Returns True if an install or upgrade is required based on the given revision, 316 | chart and values, False otherwise. 317 | """ 318 | values = mergeconcat(*values) if values else {} 319 | if current_revision: 320 | # If the current revision was not deployed successfully, always redeploy 321 | if current_revision.status != ReleaseRevisionStatus.DEPLOYED: 322 | return True 323 | # If the chart has changed from the deployed release, we should redeploy 324 | revision_chart = await current_revision.chart_metadata() 325 | if revision_chart.name != chart.metadata.name: 326 | return True 327 | if revision_chart.version != chart.metadata.version: 328 | return True 329 | # If the values have changed from the deployed release, we should redeploy 330 | revision_values = await current_revision.values() 331 | if revision_values != values: 332 | return True 333 | # If the chart and values are the same, there is nothing to do 334 | return False 335 | else: 336 | # No current revision - install is always required 337 | return True 338 | 339 | async def ensure_release( 340 | self, 341 | release_name: str, 342 | chart: Chart, 343 | *values: t.Dict[str, t.Any], 344 | atomic: bool = False, 345 | cleanup_on_fail: bool = False, 346 | create_namespace: bool = True, 347 | description: t.Optional[str] = None, 348 | force: bool = False, 349 | namespace: t.Optional[str] = None, 350 | no_hooks: bool = False, 351 | reset_values: bool = False, 352 | reuse_values: bool = False, 353 | skip_crds: bool = False, 354 | timeout: t.Union[int, str, None] = None, 355 | wait: bool = False 356 | ) -> ReleaseRevision: 357 | """ 358 | Ensures the named release matches the given chart and values and return the current 359 | revision. 360 | 361 | It the release must be rolled back or deleted in order to be proceedable, this method 362 | will ensure that happens. It will also only make a new release if the chart and/or 363 | values have changed. 364 | """ 365 | values = mergeconcat(*values) if values else {} 366 | current_revision = await self.get_proceedable_revision( 367 | release_name, 368 | namespace = namespace, 369 | timeout = timeout 370 | ) 371 | should_install_or_upgrade = await self.should_install_or_upgrade_release( 372 | current_revision, 373 | chart, 374 | values 375 | ) 376 | if should_install_or_upgrade: 377 | return await self.install_or_upgrade_release( 378 | release_name, 379 | chart, 380 | values, 381 | atomic = atomic, 382 | cleanup_on_fail = cleanup_on_fail, 383 | create_namespace = create_namespace, 384 | description = description, 385 | force = force, 386 | namespace = namespace, 387 | no_hooks = no_hooks, 388 | reset_values = reset_values, 389 | reuse_values = reuse_values, 390 | skip_crds = skip_crds, 391 | timeout = timeout, 392 | wait = wait 393 | ) 394 | else: 395 | return current_revision 396 | 397 | async def uninstall_release( 398 | self, 399 | release_name: str, 400 | *, 401 | dry_run: bool = False, 402 | keep_history: bool = False, 403 | namespace: t.Optional[str] = None, 404 | no_hooks: bool = False, 405 | timeout: t.Union[int, str, None] = None, 406 | wait: bool = False 407 | ): 408 | """ 409 | Uninstall the named release. 410 | """ 411 | try: 412 | await self._command.uninstall( 413 | release_name, 414 | dry_run = dry_run, 415 | keep_history = keep_history, 416 | namespace = namespace, 417 | no_hooks = no_hooks, 418 | timeout = timeout, 419 | wait = wait 420 | ) 421 | except ReleaseNotFoundError: 422 | # If the release does not exist, it is deleted :-) 423 | pass 424 | -------------------------------------------------------------------------------- /pyhelm3/command.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import pathlib 5 | import re 6 | import shlex 7 | import shutil 8 | import tempfile 9 | import typing as t 10 | 11 | import yaml 12 | 13 | from . import errors 14 | 15 | 16 | class SafeLoader(yaml.SafeLoader): 17 | """ 18 | We use a custom YAML loader that doesn't bork on plain equals '=' signs. 19 | 20 | It was originally designated with a special meaning, but noone uses it: 21 | 22 | https://github.com/yaml/pyyaml/issues/89 23 | https://yaml.org/type/value.html 24 | """ 25 | @staticmethod 26 | def construct_value(loader, node): 27 | return loader.construct_scalar(node) 28 | 29 | SafeLoader.add_constructor("tag:yaml.org,2002:value", SafeLoader.construct_value) 30 | 31 | 32 | CHART_METADATA_TEMPLATE = """ 33 | {{- with .Release.Chart.Metadata }} 34 | apiVersion: {{ .APIVersion }} 35 | name: {{ printf "%q" .Name }} 36 | version: {{ printf "%q" .Version }} 37 | {{- with .KubeVersion }} 38 | kubeVersion: {{ printf "%q" . }} 39 | {{- end }} 40 | {{- with .Description }} 41 | description: {{ printf "%q" . }} 42 | {{- end }} 43 | {{- with .Type }} 44 | type: {{ printf "%q" . }} 45 | {{- end }} 46 | {{- with .Keywords }} 47 | keywords: 48 | {{- range . }} 49 | - {{ printf "%q" . }} 50 | {{- end }} 51 | {{- end }} 52 | {{- with .Home }} 53 | home: {{ printf "%q" . }} 54 | {{- end }} 55 | {{- with .Sources }} 56 | sources: 57 | {{- range . }} 58 | - {{ printf "%q" . }} 59 | {{- end }} 60 | {{- end }} 61 | {{- with .Dependencies }} 62 | dependencies: 63 | {{- range . }} 64 | - name: {{ printf "%q" .Name }} 65 | version: {{ printf "%q" .Version }} 66 | {{- with .Repository }} 67 | repository: {{ printf "%q" . }} 68 | {{- end }} 69 | {{- with .Condition }} 70 | condition: {{ printf "%q" . }} 71 | {{- end }} 72 | {{- with .Tags }} 73 | tags: 74 | {{- range . }} 75 | - {{ printf "%q" . }} 76 | {{- end }} 77 | {{- end }} 78 | {{- with .ImportValues }} 79 | import-values: 80 | {{- range . }} 81 | {{- if eq "string" (printf "%T" .) }} 82 | - {{ printf "%q" . }} 83 | {{- else }} 84 | - 85 | {{- range $k, $v := . }} 86 | {{ $k }}: {{ printf "%q" $v }} 87 | {{- end }} 88 | {{- end }} 89 | {{- end }} 90 | {{- end }} 91 | {{- with .Alias }} 92 | alias: {{ printf "%q" . }} 93 | {{- end }} 94 | {{- end }} 95 | {{- end }} 96 | {{- with .Maintainers }} 97 | maintainers: 98 | {{- range . }} 99 | - name: {{ printf "%q" .Name }} 100 | {{- with .Email }} 101 | email: {{ printf "%q" . }} 102 | {{- end }} 103 | {{- with .URL }} 104 | url: {{ printf "%q" . }} 105 | {{- end }} 106 | {{- end }} 107 | {{- end }} 108 | {{- with .Icon }} 109 | icon: {{ printf "%q" . }} 110 | {{- end }} 111 | {{- with .AppVersion }} 112 | appVersion: {{ printf "%q" . }} 113 | {{- end }} 114 | {{- if .Deprecated }} 115 | deprecated: true 116 | {{- end }} 117 | {{- with .Annotations }} 118 | annotations: 119 | {{- range $k, $v := . }} 120 | {{ $k }}: {{ printf "%q" $v }} 121 | {{- end }} 122 | {{- end }} 123 | {{- end }} 124 | """ 125 | 126 | 127 | #: Bound type var for forward references 128 | CommandType = t.TypeVar("CommandType", bound = "Command") 129 | 130 | 131 | CHART_NOT_FOUND = re.compile(r"chart \"[^\"]+\" (version \"[^\"]+\" )?not found") 132 | CONNECTION_ERROR = re.compile(r"(read: operation timed out|connect: network is unreachable)") 133 | 134 | 135 | class Command: 136 | """ 137 | Class presenting an async interface around the Helm CLI. 138 | """ 139 | def __init__( 140 | self, 141 | *, 142 | default_timeout: t.Union[int, str] = "5m", 143 | executable: str = "helm", 144 | history_max_revisions: int = 10, 145 | insecure_skip_tls_verify: bool = False, 146 | kubeconfig: t.Optional[pathlib.Path] = None, 147 | kubecontext: t.Optional[str] = None, 148 | kubeapiserver: t.Optional[str] = None, 149 | kubetoken: t.Optional[str] = None, 150 | unpack_directory: t.Optional[str] = None 151 | ): 152 | self._logger = logging.getLogger(__name__) 153 | self._default_timeout = default_timeout 154 | self._executable = executable 155 | self._history_max_revisions = history_max_revisions 156 | self._insecure_skip_tls_verify = insecure_skip_tls_verify 157 | self._kubeconfig = kubeconfig 158 | self._kubecontext = kubecontext 159 | self._kubeapiserver = kubeapiserver 160 | self._kubetoken = kubetoken 161 | self._unpack_directory = unpack_directory 162 | 163 | def _log_format(self, argument): 164 | argument = str(argument) 165 | if argument == "-": 166 | return "" 167 | elif "\n" in argument: 168 | return "" 169 | else: 170 | return argument 171 | 172 | async def run(self, command: t.List[str], input: t.Optional[bytes] = None) -> bytes: 173 | """ 174 | Run the given Helm command with the given input as stdin and 175 | """ 176 | command = [self._executable] + command 177 | if self._kubeconfig: 178 | command.extend(["--kubeconfig", self._kubeconfig]) 179 | if self._kubecontext: 180 | command.extend(["--kube-context", self._kubecontext]) 181 | if self._kubeapiserver: 182 | command.extend(["--kube-apiserver", self._kubeapiserver]) 183 | if self._kubetoken: 184 | command.extend(["--kube-token", self._kubetoken]) 185 | if self._insecure_skip_tls_verify: 186 | command.append("--kube-insecure-skip-tls-verify") 187 | # The command must be made up of str and bytes, so convert anything that isn't 188 | shell_formatted_command = shlex.join( 189 | part if isinstance(part, (str, bytes)) else str(part) 190 | for part in command 191 | ) 192 | log_formatted_command = shlex.join(self._log_format(part) for part in command) 193 | self._logger.info("running command: %s", log_formatted_command) 194 | proc = await asyncio.create_subprocess_shell( 195 | shell_formatted_command, 196 | # Only make stdin a pipe if we have input to feed it 197 | stdin = asyncio.subprocess.PIPE if input is not None else None, 198 | stdout = asyncio.subprocess.PIPE, 199 | stderr = asyncio.subprocess.PIPE 200 | ) 201 | try: 202 | stdout, stderr = await proc.communicate(input) 203 | except asyncio.CancelledError: 204 | # If the asyncio task is cancelled, terminate the Helm process but let the 205 | # process handle the termination and exit 206 | # We occassionally see a ProcessLookupError here if the process finished between 207 | # us being cancelled and terminating the process, which we ignore as that is our 208 | # target state anyway 209 | try: 210 | proc.terminate() 211 | _ = await proc.communicate() 212 | except ProcessLookupError: 213 | pass 214 | # Once the process has exited, re-raise the cancelled error 215 | raise 216 | if proc.returncode == 0: 217 | self._logger.info("command succeeded: %s", log_formatted_command) 218 | return stdout 219 | else: 220 | self._logger.warning("command failed: %s", log_formatted_command) 221 | stderr_str = stderr.decode().lower() 222 | # Parse some expected errors into specific exceptions 223 | if "context canceled" in stderr_str: 224 | error_cls = errors.CommandCancelledError 225 | # Any error referencing etcd is a connection error 226 | # This must be before other rules, as it sometimes occurs alonside a not found error 227 | elif "etcdserver" in stderr_str: 228 | error_cls = errors.ConnectionError 229 | elif "release: not found" in stderr_str: 230 | error_cls = errors.ReleaseNotFoundError 231 | elif "failed to render chart" in stderr_str: 232 | error_cls = errors.FailedToRenderChartError 233 | elif "execution error" in stderr_str: 234 | error_cls = errors.FailedToRenderChartError 235 | elif "rendered manifests contain a resource that already exists" in stderr_str: 236 | error_cls = errors.ResourceAlreadyExistsError 237 | elif "is invalid" in stderr_str: 238 | error_cls = errors.InvalidResourceError 239 | elif CHART_NOT_FOUND.search(stderr_str) is not None: 240 | error_cls = errors.ChartNotFoundError 241 | elif CONNECTION_ERROR.search(stderr_str) is not None: 242 | error_cls = errors.ConnectionError 243 | else: 244 | error_cls = errors.Error 245 | raise error_cls(proc.returncode, stdout, stderr) 246 | 247 | async def diff_release( 248 | self, 249 | release_name: str, 250 | other_release_name: str, 251 | *, 252 | # The number of lines of context to show around each diff 253 | context_lines: t.Optional[int] = None, 254 | namespace: t.Optional[str] = None, 255 | # Indicates whether to show secret values in the diff 256 | show_secrets: bool = True 257 | ) -> str: 258 | """ 259 | Returns the diff between two releases created from the same chart. 260 | """ 261 | command = [ 262 | "diff", 263 | "release", 264 | release_name, 265 | other_release_name, 266 | "--no-color", 267 | "--normalize-manifests", 268 | ] 269 | if context_lines is not None: 270 | command.extend(["--context", context_lines]) 271 | if namespace: 272 | command.extend(["--namespace", namespace]) 273 | if show_secrets: 274 | command.append("--show-secrets") 275 | return (await self.run(command)).decode() 276 | 277 | async def diff_revision( 278 | self, 279 | release_name: str, 280 | revision: int, 281 | # If not specified, the diff is with latest 282 | other_revision: t.Optional[int] = None, 283 | *, 284 | # The number of lines of context to show around each diff 285 | context_lines: t.Optional[int] = None, 286 | namespace: t.Optional[str] = None, 287 | # Indicates whether to show secret values in the diff 288 | show_secrets: bool = True 289 | ) -> str: 290 | """ 291 | Returns the diff between two revisions of the specified release. 292 | 293 | If the second revision is not specified, the latest revision is used. 294 | """ 295 | command = [ 296 | "diff", 297 | "revision", 298 | release_name, 299 | revision, 300 | ] 301 | if other_revision is not None: 302 | command.append(other_revision) 303 | command.extend(["--no-color", "--normalize-manifests"]) 304 | if context_lines is not None: 305 | command.extend(["--context", context_lines]) 306 | if namespace: 307 | command.extend(["--namespace", namespace]) 308 | if show_secrets: 309 | command.append("--show-secrets") 310 | return (await self.run(command)).decode() 311 | 312 | async def diff_rollback( 313 | self, 314 | release_name: str, 315 | # The revision to simulate rolling back to 316 | revision: t.Optional[int] = None, 317 | *, 318 | # The number of lines of context to show around each diff 319 | context_lines: t.Optional[int] = None, 320 | namespace: t.Optional[str] = None, 321 | # Indicates whether to show secret values in the diff 322 | show_secrets: bool = True 323 | ) -> str: 324 | """ 325 | Returns the diff that would result from rolling back the given release 326 | to the specified revision. 327 | """ 328 | command = [ 329 | "diff", 330 | "rollback", 331 | release_name, 332 | ] 333 | if revision is not None: 334 | command.append(revision) 335 | command.extend(["--no-color", "--normalize-manifests"]) 336 | if context_lines is not None: 337 | command.extend(["--context", context_lines]) 338 | if namespace: 339 | command.extend(["--namespace", namespace]) 340 | if show_secrets: 341 | command.append("--show-secrets") 342 | return (await self.run(command)).decode() 343 | 344 | async def diff_upgrade( 345 | self, 346 | release_name: str, 347 | chart_ref: t.Union[pathlib.Path, str], 348 | values: t.Optional[t.Dict[str, t.Any]] = None, 349 | *, 350 | # The number of lines of context to show around each diff 351 | context_lines: t.Optional[int] = None, 352 | devel: bool = False, 353 | dry_run: bool = False, 354 | namespace: t.Optional[str] = None, 355 | no_hooks: bool = False, 356 | repo: t.Optional[str] = None, 357 | reset_values: bool = False, 358 | reuse_values: bool = False, 359 | # Indicates whether to show secret values in the diff 360 | show_secrets: bool = True, 361 | version: t.Optional[str] = None 362 | ) -> str: 363 | """ 364 | Returns the diff that would result from rolling back the given release 365 | to the specified revision. 366 | """ 367 | command = [ 368 | "diff", 369 | "upgrade", 370 | release_name, 371 | chart_ref, 372 | "--allow-unreleased", 373 | "--no-color", 374 | "--normalize-manifests", 375 | # Disable OpenAPI validation as we still want the diff to work when CRDs change 376 | "--disable-openapi-validation", 377 | # We pass the values using stdin 378 | "--values", "-", 379 | ] 380 | if context_lines is not None: 381 | command.extend(["--context", context_lines]) 382 | if devel: 383 | command.append("--devel") 384 | if dry_run: 385 | command.append("--dry-run") 386 | if namespace: 387 | command.extend(["--namespace", namespace]) 388 | if no_hooks: 389 | command.append("--no-hooks") 390 | if repo: 391 | command.extend(["--repo", repo]) 392 | if reset_values: 393 | command.append("--reset-values") 394 | if reuse_values: 395 | command.append("--reuse-values") 396 | if show_secrets: 397 | command.append("--show-secrets") 398 | if version: 399 | command.extend(["--version", version]) 400 | return (await self.run(command, json.dumps(values or {}).encode())).decode() 401 | 402 | async def diff_version(self) -> str: 403 | """ 404 | Returns the version of the Helm diff plugin (https://github.com/databus23/helm-diff). 405 | """ 406 | return (await self.run(["diff", "version"])).decode() 407 | 408 | async def get_chart_metadata( 409 | self, 410 | release_name: str, 411 | *, 412 | namespace: t.Optional[str] = None, 413 | revision: t.Optional[int] = None 414 | ): 415 | """ 416 | Returns metadata for the chart that was used to deploy the release. 417 | """ 418 | # There is no native command for this (!!!!) so use the templating 419 | # functionality to template out some YAML 420 | command = [ 421 | "get", 422 | "all", 423 | release_name, 424 | # Use the chart metadata template 425 | "--template", CHART_METADATA_TEMPLATE 426 | ] 427 | if namespace: 428 | command.extend(["--namespace", namespace]) 429 | if revision is not None: 430 | command.extend(["--revision", revision]) 431 | return yaml.load(await self.run(command), Loader = SafeLoader) 432 | 433 | async def get_hooks( 434 | self, 435 | release_name: str, 436 | *, 437 | namespace: t.Optional[str] = None, 438 | revision: t.Optional[int] = None 439 | ) -> t.Iterable[t.Dict[str, t.Any]]: 440 | """ 441 | Returns the hooks for the specified release. 442 | """ 443 | command = ["get", "hooks", release_name] 444 | if revision is not None: 445 | command.extend(["--revision", revision]) 446 | if namespace: 447 | command.extend(["--namespace", namespace]) 448 | return yaml.load_all(await self.run(command), Loader = SafeLoader) 449 | 450 | async def get_resources( 451 | self, 452 | release_name: str, 453 | *, 454 | namespace: t.Optional[str] = None, 455 | revision: t.Optional[int] = None 456 | ) -> t.Iterable[t.Dict[str, t.Any]]: 457 | """ 458 | Returns the resources for the specified release. 459 | """ 460 | command = ["get", "manifest", release_name] 461 | if revision is not None: 462 | command.extend(["--revision", revision]) 463 | if namespace: 464 | command.extend(["--namespace", namespace]) 465 | return yaml.load_all(await self.run(command), Loader = SafeLoader) 466 | 467 | async def get_values( 468 | self, 469 | release_name: str, 470 | *, 471 | computed: bool = False, 472 | namespace: t.Optional[str] = None, 473 | revision: t.Optional[int] = None 474 | ) -> t.Dict[str, t.Any]: 475 | """ 476 | Returns the values for the specified release. 477 | 478 | Optionally, the full computed values can be requested. 479 | """ 480 | command = ["get", "values", release_name, "--output", "json"] 481 | if computed: 482 | command.append("--all") 483 | if revision is not None: 484 | command.extend(["--revision", revision]) 485 | if namespace: 486 | command.extend(["--namespace", namespace]) 487 | return json.loads(await self.run(command)) or {} 488 | 489 | async def history( 490 | self, 491 | release_name: str, 492 | *, 493 | max_revisions: int = 256, 494 | namespace: t.Optional[str] = None 495 | ) -> t.Iterable[t.Dict[str, t.Any]]: 496 | """ 497 | Returns the historical revisions for the specified release. 498 | 499 | The maximum number of revisions to return can be specified (defaults to 256). 500 | """ 501 | command = ["history", release_name, "--output", "json", "--max", max_revisions] 502 | if namespace: 503 | command.extend(["--namespace", namespace]) 504 | return json.loads(await self.run(command)) 505 | 506 | async def install_or_upgrade( 507 | self, 508 | release_name: str, 509 | chart_ref: t.Union[pathlib.Path, str], 510 | values: t.Optional[t.Dict[str, t.Any]] = None, 511 | *, 512 | atomic: bool = False, 513 | cleanup_on_fail: bool = False, 514 | create_namespace: bool = True, 515 | description: t.Optional[str] = None, 516 | devel: bool = False, 517 | dry_run: bool = False, 518 | force: bool = False, 519 | namespace: t.Optional[str] = None, 520 | no_hooks: bool = False, 521 | repo: t.Optional[str] = None, 522 | reset_values: bool = False, 523 | reuse_values: bool = False, 524 | skip_crds: bool = False, 525 | timeout: t.Union[int, str, None] = None, 526 | version: t.Optional[str] = None, 527 | wait: bool = False 528 | ) -> t.Iterable[t.Dict[str, t.Any]]: 529 | """ 530 | Installs or upgrades the specified release using the given chart and values. 531 | """ 532 | command = [ 533 | "upgrade", 534 | release_name, 535 | chart_ref, 536 | "--history-max", self._history_max_revisions, 537 | "--install", 538 | "--output", "json", 539 | # Use the default timeout unless an override is specified 540 | "--timeout", timeout if timeout is not None else self._default_timeout, 541 | # We send the values in on stdin 542 | "--values", "-", 543 | ] 544 | if atomic: 545 | command.append("--atomic") 546 | if cleanup_on_fail: 547 | command.append("--cleanup-on-fail") 548 | if create_namespace: 549 | command.append("--create-namespace") 550 | if description: 551 | command.extend(["--description", description]) 552 | if devel: 553 | command.append("--devel") 554 | if dry_run: 555 | command.append("--dry-run") 556 | if force: 557 | command.append("--force") 558 | if namespace: 559 | command.extend(["--namespace", namespace]) 560 | if no_hooks: 561 | command.append("--no-hooks") 562 | if repo: 563 | command.extend(["--repo", repo]) 564 | if reset_values: 565 | command.append("--reset-values") 566 | if reuse_values: 567 | command.append("--reuse-values") 568 | if skip_crds: 569 | command.append("--skip-crds") 570 | if version: 571 | command.extend(["--version", version]) 572 | if wait: 573 | command.extend(["--wait", "--wait-for-jobs"]) 574 | return json.loads(await self.run(command, json.dumps(values or {}).encode())) 575 | 576 | async def list( 577 | self, 578 | *, 579 | all: bool = False, 580 | all_namespaces: bool = False, 581 | include_deployed: bool = True, 582 | include_failed: bool = False, 583 | include_pending: bool = False, 584 | include_superseded: bool = False, 585 | include_uninstalled: bool = False, 586 | include_uninstalling: bool = False, 587 | max_releases: int = 256, 588 | namespace: t.Optional[str] = None, 589 | sort_by_date: bool = False, 590 | sort_reversed: bool = False 591 | ) -> t.Iterable[t.Dict[str, t.Any]]: 592 | """ 593 | Returns the list of releases that match the given options. 594 | """ 595 | command = ["list", "--max", max_releases, "--output", "json"] 596 | if all: 597 | command.append("--all") 598 | if all_namespaces: 599 | command.append("--all-namespaces") 600 | if include_deployed: 601 | command.append("--deployed") 602 | if include_failed: 603 | command.append("--failed") 604 | if include_pending: 605 | command.append("--pending") 606 | if include_superseded: 607 | command.append("--superseded") 608 | if include_uninstalled: 609 | command.append("--uninstalled") 610 | if include_uninstalling: 611 | command.append("--uninstalling") 612 | if namespace: 613 | command.extend(["--namespace", namespace]) 614 | if sort_by_date: 615 | command.append("--date") 616 | if sort_reversed: 617 | command.append("--reverse") 618 | return json.loads(await self.run(command)) 619 | 620 | async def pull( 621 | self, 622 | chart_ref: t.Union[pathlib.Path, str], 623 | *, 624 | devel: bool = False, 625 | repo: t.Optional[str] = None, 626 | version: t.Optional[str] = None 627 | ) -> pathlib.Path: 628 | """ 629 | Fetch a chart from a remote location and unpack it locally. 630 | 631 | Returns the path of the directory into which the chart was downloaded and unpacked. 632 | """ 633 | # Make a directory to unpack into 634 | destination = tempfile.mkdtemp(prefix = "helm.", dir = self._unpack_directory) 635 | command = ["pull", chart_ref, "--destination", destination, "--untar"] 636 | if devel: 637 | command.append("--devel") 638 | if repo: 639 | command.extend(["--repo", repo]) 640 | if version: 641 | command.extend(["--version", version]) 642 | await self.run(command) 643 | return pathlib.Path(destination).resolve() 644 | 645 | async def repo_list(self) -> t.Iterable[t.Dict[str, t.Any]]: 646 | """ 647 | Lists the available Helm repositories. 648 | """ 649 | return json.loads(await self.run(["repo", "list", "--output", "json"])) 650 | 651 | async def repo_add(self, name: str, url: str): 652 | """ 653 | Adds a repository to the available Helm repositories. 654 | 655 | Returns the new repo list on success. 656 | """ 657 | command = ["repo", "add", name, url, "--force-update"] 658 | await self.run(command) 659 | 660 | async def repo_update(self, *names: str): 661 | """ 662 | Updates the chart indexes for the specified repositories. 663 | 664 | If no repositories are given, all repositories are updated. 665 | 666 | Returns the repo list on success. 667 | """ 668 | await self.run(["repo", "update", "--fail-on-repo-update-fail"] + list(names)) 669 | 670 | async def repo_remove(self, name: str): 671 | """ 672 | Removes the specified chart. 673 | 674 | Returns the new repo list on success. 675 | """ 676 | try: 677 | await self.run(["repo", "remove", name]) 678 | except errors.Error as exc: 679 | if "no repo named" not in exc.stderr.decode().lower(): 680 | raise 681 | 682 | async def rollback( 683 | self, 684 | release_name: str, 685 | revision: t.Optional[int], 686 | *, 687 | cleanup_on_fail: bool = False, 688 | dry_run: bool = False, 689 | force: bool = False, 690 | namespace: t.Optional[str] = None, 691 | no_hooks: bool = False, 692 | recreate_pods: bool = False, 693 | timeout: t.Union[int, str, None] = None, 694 | wait: bool = False 695 | ): 696 | """ 697 | Rollback the specified release to the specified revision. 698 | """ 699 | command = [ 700 | "rollback", 701 | release_name, 702 | ] 703 | if revision is not None: 704 | command.append(revision) 705 | command.extend([ 706 | "--history-max", self._history_max_revisions, 707 | # Use the default timeout unless an override is specified 708 | "--timeout", timeout if timeout is not None else self._default_timeout, 709 | ]) 710 | if cleanup_on_fail: 711 | command.append("--cleanup-on-fail") 712 | if dry_run: 713 | command.append("--dry-run") 714 | if force: 715 | command.append("--force") 716 | if namespace: 717 | command.extend(["--namespace", namespace]) 718 | if no_hooks: 719 | command.append("--no-hooks") 720 | if recreate_pods: 721 | command.append("--recreate-pods") 722 | if wait: 723 | command.extend(["--wait", "--wait-for-jobs"]) 724 | await self.run(command) 725 | 726 | async def search( 727 | self, 728 | search_keyword: t.Optional[str] = None, 729 | *, 730 | all_versions: bool = False, 731 | devel: bool = False, 732 | version_constraints: t.Optional[str] = None 733 | ) -> t.Iterable[t.Dict[str, t.Any]]: 734 | """ 735 | Search the available Helm repositories for charts matching the specified constraints. 736 | """ 737 | command = ["search", "repo", "--output", "json"] 738 | if search_keyword: 739 | command.append(search_keyword) 740 | if all_versions: 741 | command.append("--versions") 742 | if devel: 743 | command.append("--devel") 744 | if version_constraints: 745 | command.extend(["--version", version_constraints]) 746 | return json.loads(await self.run(command)) 747 | 748 | async def show_chart( 749 | self, 750 | chart_ref: t.Union[pathlib.Path, str], 751 | *, 752 | devel: bool = False, 753 | repo: t.Optional[str] = None, 754 | version: t.Optional[str] = None 755 | ) -> t.Dict[str, t.Any]: 756 | """ 757 | Returns the contents of Chart.yaml for the specified chart. 758 | """ 759 | command = ["show", "chart", chart_ref] 760 | if devel: 761 | command.append("--devel") 762 | if repo: 763 | command.extend(["--repo", repo]) 764 | if version: 765 | command.extend(["--version", version]) 766 | return yaml.load(await self.run(command), Loader = SafeLoader) 767 | 768 | async def show_crds( 769 | self, 770 | chart_ref: t.Union[pathlib.Path, str], 771 | *, 772 | devel: bool = False, 773 | repo: t.Optional[str] = None, 774 | version: t.Optional[str] = None 775 | ) -> t.Iterable[t.Dict[str, t.Any]]: 776 | """ 777 | Returns the CRDs for the specified chart. 778 | """ 779 | # Until https://github.com/helm/helm/issues/11261 is fixed, we must manually 780 | # unpack the chart and parse the files in the ./crds directory ourselves 781 | # This is what the implementation should be 782 | # command = ["show", "crds", chart_ref] 783 | # if devel: 784 | # command.append("--devel") 785 | # if repo: 786 | # command.extend(["--repo", repo]) 787 | # if version: 788 | # command.extend(["--version", version]) 789 | # return return yaml.load_all(await self.run(command), Loader = SafeLoader) 790 | 791 | # If ephemeral_path is set, it will be deleted at the end of the method 792 | ephemeral_path = None 793 | try: 794 | if repo: 795 | # If a repo is given, assume that the chart ref is a chart name in that repo 796 | ephemeral_path = await self.pull( 797 | chart_ref, 798 | devel = devel, 799 | repo = repo, 800 | version = version 801 | ) 802 | chart_directory = next(ephemeral_path.glob("**/Chart.yaml")).parent 803 | else: 804 | # If not, we have either a path (directory or archive) or a URL to a chart 805 | try: 806 | chart_path = pathlib.Path(chart_ref).resolve(strict = True) 807 | except (TypeError, ValueError, FileNotFoundError): 808 | # Assume we have a URL that needs pulling 809 | ephemeral_path = await self.pull(chart_ref) 810 | chart_directory = next(ephemeral_path.glob("**/Chart.yaml")).parent 811 | else: 812 | if chart_path.is_dir(): 813 | # Just make sure that the directory is a chart 814 | chart_directory = next(chart_path.glob("**/Chart.yaml")).parent 815 | else: 816 | raise RuntimeError("local archive files are not currently supported") 817 | def yaml_load_all(file): 818 | with file.open() as fh: 819 | yield from yaml.load_all(fh, Loader = SafeLoader) 820 | return [ 821 | crd 822 | for crd_file in chart_directory.glob("crds/**/*.yaml") 823 | for crd in yaml_load_all(crd_file) 824 | ] 825 | finally: 826 | if ephemeral_path and ephemeral_path.is_dir(): 827 | shutil.rmtree(ephemeral_path) 828 | 829 | async def show_readme( 830 | self, 831 | chart_ref: t.Union[pathlib.Path, str], 832 | *, 833 | devel: bool = False, 834 | repo: t.Optional[str] = None, 835 | version: t.Optional[str] = None 836 | ) -> str: 837 | """ 838 | Returns the README for the specified chart. 839 | """ 840 | command = ["show", "readme", chart_ref] 841 | if devel: 842 | command.append("--devel") 843 | if repo: 844 | command.extend(["--repo", repo]) 845 | if version: 846 | command.extend(["--version", version]) 847 | return (await self.run(command)).decode() 848 | 849 | async def show_values( 850 | self, 851 | chart_ref: t.Union[pathlib.Path, str], 852 | *, 853 | devel: bool = False, 854 | repo: t.Optional[str] = None, 855 | version: t.Optional[str] = None 856 | ) -> t.Dict[str, t.Any]: 857 | """ 858 | Returns the default values for the specified chart. 859 | """ 860 | command = ["show", "values", chart_ref] 861 | if devel: 862 | command.append("--devel") 863 | if repo: 864 | command.extend(["--repo", repo]) 865 | if version: 866 | command.extend(["--version", version]) 867 | return yaml.load(await self.run(command), Loader = SafeLoader) 868 | 869 | async def status( 870 | self, 871 | release_name: str, 872 | *, 873 | namespace: t.Optional[str] = None, 874 | revision: t.Optional[int] = None, 875 | ): 876 | """ 877 | Get the status of the specified release. 878 | """ 879 | command = ["status", release_name, "--output", "json"] 880 | if namespace: 881 | command.extend(["--namespace", namespace]) 882 | if revision: 883 | command.extend(["--revision", revision]) 884 | return json.loads(await self.run(command)) 885 | 886 | async def template( 887 | self, 888 | release_name: str, 889 | chart_ref: t.Union[pathlib.Path, str], 890 | values: t.Optional[t.Dict[str, t.Any]] = None, 891 | *, 892 | devel: bool = False, 893 | include_crds: bool = False, 894 | is_upgrade: bool = False, 895 | namespace: t.Optional[str] = None, 896 | no_hooks: bool = False, 897 | repo: t.Optional[str] = None, 898 | version: t.Optional[str] = None, 899 | ) -> t.Iterable[t.Dict[str, t.Any]]: 900 | """ 901 | Renders the chart templates and returns the resources. 902 | """ 903 | command = [ 904 | "template", 905 | release_name, 906 | chart_ref, 907 | "--include-crds" if include_crds else "--skip-crds", 908 | # We send the values in on stdin 909 | "--values", "-", 910 | ] 911 | if devel: 912 | command.append("--devel") 913 | if is_upgrade: 914 | command.append("--is-upgrade") 915 | if namespace: 916 | command.extend(["--namespace", namespace]) 917 | if no_hooks: 918 | command.append("--no-hooks") 919 | if repo: 920 | command.extend(["--repo", repo]) 921 | if version: 922 | command.extend(["--version", version]) 923 | return yaml.load_all( 924 | await self.run(command, json.dumps(values or {}).encode()), 925 | Loader = SafeLoader 926 | ) 927 | 928 | async def uninstall( 929 | self, 930 | release_name: str, 931 | *, 932 | dry_run: bool = False, 933 | keep_history: bool = False, 934 | namespace: t.Optional[str] = None, 935 | no_hooks: bool = False, 936 | timeout: t.Union[int, str, None] = None, 937 | wait: bool = False 938 | ): 939 | """ 940 | Uninstall the specified release. 941 | """ 942 | command = [ 943 | "uninstall", 944 | release_name, 945 | # Use the default timeout unless an override is specified 946 | "--timeout", timeout if timeout is not None else self._default_timeout, 947 | ] 948 | if dry_run: 949 | command.append("--dry-run") 950 | if keep_history: 951 | command.append("--keep-history") 952 | if namespace: 953 | command.extend(["--namespace", namespace]) 954 | if no_hooks: 955 | command.append("--no-hooks") 956 | if wait: 957 | command.extend(["--wait"]) 958 | await self.run(command) 959 | 960 | async def version(self) -> str: 961 | """ 962 | Returns the Helm version. 963 | """ 964 | return (await self.run(["version", "--template", "{{ .Version }}"])).decode() 965 | -------------------------------------------------------------------------------- /pyhelm3/errors.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | """ 3 | Raised when an error occurs with a Helm command. 4 | """ 5 | def __init__(self, returncode: int, stdout: bytes, stderr: bytes): 6 | self.returncode = returncode 7 | self.stdout = stdout 8 | self.stderr = stderr 9 | super().__init__(stderr.decode()) 10 | 11 | 12 | class ConnectionError(Error): 13 | """ 14 | Raised when there is a problem connecting to the Kubernetes API. 15 | """ 16 | 17 | 18 | class ChartNotFoundError(Error): 19 | """ 20 | Raised when a chart is not found. 21 | """ 22 | 23 | 24 | class FailedToRenderChartError(Error): 25 | """ 26 | Raised when a chart fails to render. 27 | """ 28 | 29 | 30 | class ReleaseNotFoundError(Error): 31 | """ 32 | Raised when a release is not found. 33 | """ 34 | 35 | 36 | class ResourceAlreadyExistsError(Error): 37 | """ 38 | Raised when Helm attempts to create a resource that already exists. 39 | """ 40 | 41 | 42 | class InvalidResourceError(Error): 43 | """ 44 | Raised when Helm attempts to create or update a resource in a way that is not valid. 45 | """ 46 | 47 | 48 | class CommandCancelledError(Error): 49 | """ 50 | Raised when a Helm command is cancelled. 51 | """ 52 | -------------------------------------------------------------------------------- /pyhelm3/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import enum 3 | import pathlib 4 | import typing as t 5 | 6 | import yaml 7 | 8 | from pydantic import ( 9 | BaseModel, 10 | TypeAdapter, 11 | Field, 12 | PrivateAttr, 13 | DirectoryPath, 14 | FilePath, 15 | AnyUrl as PydanticAnyUrl, 16 | HttpUrl as PydanticHttpUrl, 17 | constr, 18 | field_validator 19 | ) 20 | 21 | from typing_extensions import Annotated 22 | OCIPath = Annotated[str, Field(pattern=r"oci:\/\/*")] 23 | 24 | from pydantic.functional_validators import AfterValidator 25 | 26 | from .command import Command, SafeLoader 27 | 28 | 29 | class ModelWithCommand(BaseModel): 30 | """ 31 | Base class for a model that has a Helm command object. 32 | """ 33 | # The command object that is used to invoke Helm 34 | _command: Command = PrivateAttr() 35 | 36 | def __init__(self, _command: Command, **kwargs): 37 | super().__init__(**kwargs) 38 | self._command = _command 39 | 40 | 41 | #: Type for a non-empty string 42 | NonEmptyString = constr(min_length = 1) 43 | 44 | 45 | #: Type for a name (chart or release) 46 | Name = constr(pattern = r"^[a-z0-9-]+$") 47 | 48 | 49 | #: Type for a SemVer version 50 | SemVerVersion = constr(pattern = r"^v?\d+\.\d+\.\d+(-[a-zA-Z0-9\.\-]+)?(\+[a-zA-Z0-9\.\-]+)?$") 51 | 52 | 53 | #: Type variables for forward references to the chart and release types 54 | ChartType = t.TypeVar("ChartType", bound = "Chart") 55 | ReleaseType = t.TypeVar("ReleaseType", bound = "Release") 56 | ReleaseRevisionType = t.TypeVar("ReleaseRevisionType", bound = "ReleaseRevision") 57 | 58 | 59 | #: Type annotation for validating a string using a Pydantic type 60 | def validate_str_as(validate_type): 61 | adapter = TypeAdapter(validate_type) 62 | return lambda v: str(adapter.validate_python(v)) 63 | 64 | 65 | #: Annotated string types for URLs 66 | AnyUrl = t.Annotated[str, AfterValidator(validate_str_as(PydanticAnyUrl))] 67 | HttpUrl = t.Annotated[str, AfterValidator(validate_str_as(PydanticHttpUrl))] 68 | 69 | 70 | class ChartDependency(BaseModel): 71 | """ 72 | Model for a chart dependency. 73 | """ 74 | name: Name = Field( 75 | ..., 76 | description = "The name of the chart." 77 | ) 78 | version: NonEmptyString = Field( 79 | ..., 80 | description = "The version of the chart. Can be a SemVer range." 81 | ) 82 | repository: str = Field( 83 | "", 84 | description = "The repository URL or alias." 85 | ) 86 | condition: t.Optional[NonEmptyString] = Field( 87 | None, 88 | description = "A yaml path that resolves to a boolean, used for enabling/disabling the chart." 89 | ) 90 | tags: t.List[NonEmptyString] = Field( 91 | default_factory = list, 92 | description = "Tags can be used to group charts for enabling/disabling together." 93 | ) 94 | import_values: t.List[t.Union[t.Dict[str, str], str]] = Field( 95 | default_factory = list, 96 | alias = "import-values", 97 | description = ( 98 | "Mapping of source values to parent key to be imported. " 99 | "Each item can be a string or pair of child/parent sublist items." 100 | ) 101 | ) 102 | alias: t.Optional[NonEmptyString] = Field( 103 | None, 104 | description = "Alias to be used for the chart." 105 | ) 106 | 107 | 108 | class ChartMaintainer(BaseModel): 109 | """ 110 | Model for the maintainer of a chart. 111 | """ 112 | name: NonEmptyString = Field( 113 | ..., 114 | description = "The maintainer's name." 115 | ) 116 | email: t.Optional[NonEmptyString] = Field( 117 | None, 118 | description = "The maintainer's email." 119 | ) 120 | url: t.Optional[AnyUrl] = Field( 121 | None, 122 | description = "A URL for the maintainer." 123 | ) 124 | 125 | 126 | class ChartMetadata(BaseModel): 127 | """ 128 | Model for chart metadata, from Chart.yaml. 129 | """ 130 | api_version: t.Literal["v1", "v2"] = Field( 131 | ..., 132 | alias = "apiVersion", 133 | description = "The chart API version." 134 | ) 135 | name: Name = Field( 136 | ..., 137 | description = "The name of the chart." 138 | ) 139 | version: SemVerVersion = Field( 140 | ..., 141 | description = "The version of the chart." 142 | ) 143 | kube_version: t.Optional[NonEmptyString] = Field( 144 | None, 145 | alias = "kubeVersion", 146 | description = "A SemVer range of compatible Kubernetes versions for the chart." 147 | ) 148 | description: t.Optional[NonEmptyString] = Field( 149 | None, 150 | description = "A single-sentence description of the chart." 151 | ) 152 | type: t.Literal["application", "library"] = Field( 153 | "application", 154 | description = "The type of the chart." 155 | ) 156 | keywords: t.List[NonEmptyString] = Field( 157 | default_factory = list, 158 | description = "List of keywords for the chart." 159 | ) 160 | home: t.Optional[HttpUrl] = Field( 161 | None, 162 | description = "The URL of th home page for the chart." 163 | ) 164 | sources: t.List[AnyUrl] = Field( 165 | default_factory = list, 166 | description = "List of URLs to source code for this chart." 167 | ) 168 | dependencies: t.List[ChartDependency] = Field( 169 | default_factory = list, 170 | description = "List of the chart dependencies." 171 | ) 172 | maintainers: t.List[ChartMaintainer] = Field( 173 | default_factory = list, 174 | description = "List of maintainers for the chart." 175 | ) 176 | icon: t.Optional[HttpUrl] = Field( 177 | None, 178 | description = "URL to an SVG or PNG image to be used as an icon." 179 | ) 180 | app_version: t.Optional[NonEmptyString] = Field( 181 | None, 182 | alias = "appVersion", 183 | description = ( 184 | "The version of the app that this chart deploys. " 185 | "SemVer is not required." 186 | ) 187 | ) 188 | deprecated: bool = Field( 189 | False, 190 | description = "Whether this chart is deprecated." 191 | ) 192 | annotations: t.Dict[str, str] = Field( 193 | default_factory = dict, 194 | description = "Annotations for the chart." 195 | ) 196 | 197 | 198 | class Chart(ModelWithCommand): 199 | """ 200 | Model for a reference to a chart. 201 | """ 202 | ref: t.Union[DirectoryPath, FilePath, HttpUrl, Name, OCIPath] = Field( 203 | ..., 204 | description = ( 205 | "The chart reference. " 206 | "Can be a chart directory or a packaged chart archive on the local " 207 | "filesystem, the URL of a packaged chart or the name of a chart. " 208 | "When a name is given, repo must also be given and version may optionally " 209 | "be given." 210 | ) 211 | ) 212 | repo: t.Optional[HttpUrl] = Field(None, description = "The repository URL.") 213 | metadata: ChartMetadata = Field(..., description = "The metadata for the chart.") 214 | 215 | # Private attributes used to cache attributes 216 | _readme: str = PrivateAttr(None) 217 | _crds: t.List[t.Dict[str, t.Any]] = PrivateAttr(None) 218 | _values: t.Dict[str, t.Any] = PrivateAttr(None) 219 | 220 | @field_validator("ref") 221 | def ref_is_abspath(cls, v): 222 | """ 223 | If the ref is a path on the filesystem, make sure it is absolute. 224 | """ 225 | if isinstance(v, pathlib.Path): 226 | return v.resolve() 227 | else: 228 | return v 229 | 230 | async def _run_command(self, command_method): 231 | """ 232 | Runs the specified command for this chart. 233 | """ 234 | method = getattr(self._command, command_method) 235 | # We only need the kwargs if the ref is not a direct reference 236 | if isinstance(self.ref, (pathlib.Path, HttpUrl)): 237 | return await method(self.ref) 238 | else: 239 | return await method(self.ref, repo = self.repo, version = self.metadata.version) 240 | 241 | async def readme(self) -> str: 242 | """ 243 | Returns the README for the chart. 244 | """ 245 | if self._readme is None: 246 | self._readme = await self._run_command("show_readme") 247 | return self._readme 248 | 249 | async def crds(self) -> t.Iterable[t.Dict[str, t.Any]]: 250 | """ 251 | Returns the CRDs for the chart. 252 | """ 253 | if self._crds is None: 254 | self._crds = list(await self._run_command("show_crds")) 255 | return self._crds 256 | 257 | async def values(self) -> t.Dict[str, t.Any]: 258 | """ 259 | Returns the values for the chart. 260 | """ 261 | if self._values is None: 262 | self._values = await self._run_command("show_values") 263 | return self._values 264 | 265 | 266 | class Release(ModelWithCommand): 267 | """ 268 | Model for a Helm release. 269 | """ 270 | name: Name = Field( 271 | ..., 272 | description = "The name of the release." 273 | ) 274 | namespace: Name = Field( 275 | ..., 276 | description = "The namespace of the release." 277 | ) 278 | 279 | async def current_revision(self) -> ReleaseRevisionType: 280 | """ 281 | Returns the current revision for the release. 282 | """ 283 | return ReleaseRevision._from_status( 284 | await self._command.status( 285 | self.name, 286 | namespace = self.namespace 287 | ), 288 | self._command 289 | ) 290 | 291 | async def revision(self, revision: int) -> ReleaseRevisionType: 292 | """ 293 | Returns the specified revision for the release. 294 | """ 295 | return ReleaseRevision._from_status( 296 | await self._command.status( 297 | self.name, 298 | namespace = self.namespace, 299 | revision = revision 300 | ), 301 | self._command 302 | ) 303 | 304 | async def history(self, max_revisions: int = 256) -> t.Iterable[ReleaseRevisionType]: 305 | """ 306 | Returns all the revisions for the release. 307 | """ 308 | history = await self._command.history( 309 | self.name, 310 | max_revisions = max_revisions, 311 | namespace = self.namespace 312 | ) 313 | return ( 314 | ReleaseRevision( 315 | self._command, 316 | release = self, 317 | revision = revision["revision"], 318 | status = revision["status"], 319 | updated = revision["updated"], 320 | description = revision.get("description") 321 | ) 322 | for revision in history 323 | ) 324 | 325 | async def rollback( 326 | self, 327 | revision: t.Optional[int] = None, 328 | *, 329 | cleanup_on_fail: bool = False, 330 | dry_run: bool = False, 331 | force: bool = False, 332 | no_hooks: bool = False, 333 | recreate_pods: bool = False, 334 | timeout: t.Union[int, str, None] = None, 335 | wait: bool = False 336 | ) -> ReleaseRevisionType: 337 | """ 338 | Rollback this release to the specified version and return the resulting revision. 339 | 340 | If no revision is specified, it will rollback to the previous release. 341 | """ 342 | await self._command.rollback( 343 | self.name, 344 | revision, 345 | cleanup_on_fail = cleanup_on_fail, 346 | dry_run = dry_run, 347 | force = force, 348 | namespace = self.namespace, 349 | no_hooks = no_hooks, 350 | recreate_pods = recreate_pods, 351 | timeout = timeout, 352 | wait = wait 353 | ) 354 | return await self.current_revision() 355 | 356 | async def simulate_rollback( 357 | self, 358 | revision: int, 359 | *, 360 | # The number of lines of context to show around each diff 361 | context_lines: t.Optional[int] = None, 362 | # Indicates whether to show secret values in the diff 363 | show_secrets: bool = True 364 | ) -> str: 365 | """ 366 | Simulate a rollback to the specified revision and return the diff. 367 | """ 368 | return await self._command.diff_rollback( 369 | self.name, 370 | revision, 371 | context_lines = context_lines, 372 | namespace = self.namespace, 373 | show_secrets = show_secrets 374 | ) 375 | 376 | async def simulate_upgrade( 377 | self, 378 | chart: Chart, 379 | values: t.Optional[t.Dict[str, t.Any]] = None, 380 | *, 381 | # The number of lines of context to show around each diff 382 | context_lines: t.Optional[int] = None, 383 | dry_run: bool = False, 384 | no_hooks: bool = False, 385 | reset_values: bool = False, 386 | reuse_values: bool = False, 387 | # Indicates whether to show secret values in the diff 388 | show_secrets: bool = True, 389 | ) -> str: 390 | """ 391 | Simulate a rollback to the specified revision and return the diff. 392 | """ 393 | return await self._command.diff_upgrade( 394 | self.name, 395 | chart.ref, 396 | values, 397 | # The number of lines of context to show around each diff 398 | context_lines = context_lines, 399 | dry_run = dry_run, 400 | namespace = self.namespace, 401 | no_hooks = no_hooks, 402 | repo = chart.repo, 403 | reset_values = reset_values, 404 | reuse_values = reuse_values, 405 | show_secrets = show_secrets, 406 | version = chart.metadata.version 407 | ) 408 | 409 | async def upgrade( 410 | self, 411 | chart: Chart, 412 | values: t.Optional[t.Dict[str, t.Any]] = None, 413 | *, 414 | atomic: bool = False, 415 | cleanup_on_fail: bool = False, 416 | description: t.Optional[str] = None, 417 | dry_run: bool = False, 418 | force: bool = False, 419 | no_hooks: bool = False, 420 | reset_values: bool = False, 421 | reuse_values: bool = False, 422 | skip_crds: bool = False, 423 | timeout: t.Union[int, str, None] = None, 424 | wait: bool = False 425 | ) -> ReleaseRevisionType: 426 | """ 427 | Upgrade this release using the given chart and values and return the new revision. 428 | """ 429 | return ReleaseRevision._from_status( 430 | await self._command.install_or_upgrade( 431 | self.name, 432 | chart.ref, 433 | values, 434 | atomic = atomic, 435 | cleanup_on_fail = cleanup_on_fail, 436 | description = description, 437 | dry_run = dry_run, 438 | force = force, 439 | namespace = self.namespace, 440 | no_hooks = no_hooks, 441 | repo = chart.repo, 442 | reset_values = reset_values, 443 | reuse_values = reuse_values, 444 | skip_crds = skip_crds, 445 | timeout = timeout, 446 | version = chart.metadata.version, 447 | wait = wait 448 | ), 449 | self._command 450 | ) 451 | 452 | async def uninstall( 453 | self, 454 | *, 455 | dry_run: bool = False, 456 | keep_history: bool = False, 457 | no_hooks: bool = False, 458 | timeout: t.Union[int, str, None] = None, 459 | wait: bool = False 460 | ): 461 | """ 462 | Uninstalls this release. 463 | """ 464 | await self._command.uninstall( 465 | self.name, 466 | dry_run = dry_run, 467 | keep_history = keep_history, 468 | namespace = self.namespace, 469 | no_hooks = no_hooks, 470 | timeout = timeout, 471 | wait = wait 472 | ) 473 | 474 | 475 | class ReleaseRevisionStatus(str, enum.Enum): 476 | """ 477 | Enumeration of possible release statuses. 478 | """ 479 | #: Indicates that the revision is in an uncertain state 480 | UNKNOWN = "unknown" 481 | #: Indicates that the revision has been pushed to Kubernetes 482 | DEPLOYED = "deployed" 483 | #: Indicates that the revision has been uninstalled from Kubernetes 484 | UNINSTALLED = "uninstalled" 485 | #: Indicates that the revision is outdated and a newer one exists 486 | SUPERSEDED = "superseded" 487 | #: Indicates that the revision was not successfully deployed 488 | FAILED = "failed" 489 | #: Indicates that an uninstall operation is underway for this revision 490 | UNINSTALLING = "uninstalling" 491 | #: Indicates that an install operation is underway for this revision 492 | PENDING_INSTALL = "pending-install" 493 | #: Indicates that an upgrade operation is underway for this revision 494 | PENDING_UPGRADE = "pending-upgrade" 495 | #: Indicates that a rollback operation is underway for this revision 496 | PENDING_ROLLBACK = "pending-rollback" 497 | 498 | 499 | class HookEvent(str, enum.Enum): 500 | """ 501 | Enumeration of possible hook events. 502 | """ 503 | PRE_INSTALL = "pre-install" 504 | POST_INSTALL = "post-install" 505 | PRE_DELETE = "pre-delete" 506 | POST_DELETE = "post-delete" 507 | PRE_UPGRADE = "pre-upgrade" 508 | POST_UPGRADE = "post-upgrade" 509 | PRE_ROLLBACK = "pre-rollback" 510 | POST_ROLLBACK = "post-rollback" 511 | TEST = "test" 512 | 513 | 514 | class HookDeletePolicy(str, enum.Enum): 515 | """ 516 | Enumeration of possible delete policies for a hook. 517 | """ 518 | HOOK_SUCCEEDED = "hook-succeeded" 519 | HOOK_FAILED = "hook-failed" 520 | HOOK_BEFORE_HOOK_CREATION = "before-hook-creation" 521 | 522 | 523 | class HookPhase(str, enum.Enum): 524 | """ 525 | Enumeration of possible phases for a hook. 526 | """ 527 | #: Indicates that a hook is in an unknown state 528 | UNKNOWN = "Unknown" 529 | #: Indicates that a hook is currently executing 530 | RUNNING = "Running" 531 | #: Indicates that hook execution succeeded 532 | SUCCEEDED = "Succeeded" 533 | #: Indicates that hook execution failed 534 | FAILED = "Failed" 535 | 536 | 537 | class Hook(BaseModel): 538 | """ 539 | Model for a hook. 540 | """ 541 | name: NonEmptyString = Field( 542 | ..., 543 | description = "The name of the hook." 544 | ) 545 | phase: HookPhase = Field( 546 | HookPhase.UNKNOWN, 547 | description = "The phase of the hook." 548 | ) 549 | kind: NonEmptyString = Field( 550 | ..., 551 | description = "The kind of the hook." 552 | ) 553 | path: NonEmptyString = Field( 554 | ..., 555 | description = "The chart-relative path to the template that produced the hook." 556 | ) 557 | resource: t.Dict[str, t.Any] = Field( 558 | ..., 559 | description = "The resource for the hook." 560 | ) 561 | events: t.List[HookEvent] = Field( 562 | default_factory = list, 563 | description = "The events that the hook fires on." 564 | ) 565 | delete_policies: t.List[HookDeletePolicy] = Field( 566 | default_factory = list, 567 | description = "The delete policies for the hook." 568 | ) 569 | 570 | 571 | class ReleaseRevision(ModelWithCommand): 572 | """ 573 | Model for a revision of a release. 574 | """ 575 | release: ReleaseType = Field( 576 | ..., 577 | description = "The parent release of this revision." 578 | ) 579 | revision: int = Field( 580 | ..., 581 | description = "The revision number of this revision." 582 | ) 583 | status: ReleaseRevisionStatus = Field( 584 | ..., 585 | description = "The status of the revision." 586 | ) 587 | updated: datetime.datetime = Field( 588 | ..., 589 | description = "The time at which this revision was updated." 590 | ) 591 | description: t.Optional[NonEmptyString] = Field( 592 | None, 593 | description = "'Log entry' for this revision." 594 | ) 595 | notes: t.Optional[NonEmptyString] = Field( 596 | None, 597 | description = "The rendered notes for this revision, if available." 598 | ) 599 | 600 | # Optional fields if they are known at creation time 601 | chart_metadata_: t.Optional[ChartMetadata] = Field(None, alias = "chart_metadata") 602 | hooks_: t.Optional[t.List[t.Dict[str, t.Any]]] = Field(None, alias = "hooks") 603 | resources_: t.Optional[t.List[t.Dict[str, t.Any]]] = Field(None, alias = "resources") 604 | values_: t.Optional[t.Dict[str, t.Any]] = Field(None, alias = "values") 605 | 606 | def _set_from_status(self, status): 607 | # Statuses from install/upgrade have chart metadata embedded 608 | if "chart" in status: 609 | self.chart_metadata_ = ChartMetadata(**status["chart"]["metadata"]) 610 | self.hooks_ = [ 611 | Hook( 612 | name = hook["name"], 613 | phase = hook["last_run"].get("phase") or "Unknown", 614 | kind = hook["kind"], 615 | path = hook["path"], 616 | resource = yaml.load(hook["manifest"], Loader = SafeLoader), 617 | events = hook["events"], 618 | delete_policies = hook.get("delete_policies", []) 619 | ) 620 | for hook in status.get("hooks", []) 621 | ] 622 | self.resources_ = list(yaml.load_all(status["manifest"], Loader = SafeLoader)) 623 | 624 | async def _init_from_status(self): 625 | self._set_from_status( 626 | await self._command.status( 627 | self.release.name, 628 | namespace = self.release.namespace, 629 | revision = self.revision 630 | ) 631 | ) 632 | 633 | async def chart_metadata(self) -> ChartMetadata: 634 | """ 635 | Returns the metadata for the chart that was used for this revision. 636 | """ 637 | if self.chart_metadata_ is None: 638 | metadata = await self._command.get_chart_metadata( 639 | self.release.name, 640 | namespace = self.release.namespace, 641 | revision = self.revision 642 | ) 643 | self.chart_metadata_ = ChartMetadata(**metadata) 644 | return self.chart_metadata_ 645 | 646 | async def hooks(self) -> t.Iterable[Hook]: 647 | """ 648 | Returns the hooks that were executed as part of this revision. 649 | """ 650 | if self.hooks_ is None: 651 | await self._init_from_status() 652 | return self.hooks_ 653 | 654 | async def resources(self) -> t.Iterable[t.Dict[str, t.Any]]: 655 | """ 656 | Returns the resources that were created as part of this revision. 657 | """ 658 | if self.resources_ is None: 659 | await self._init_from_status() 660 | return self.resources_ 661 | 662 | async def values(self, computed: bool = False) -> t.Dict[str, t.Any]: 663 | """ 664 | Returns the values that were used for this revision. 665 | """ 666 | return await self._command.get_values( 667 | self.release.name, 668 | computed = computed, 669 | namespace = self.release.namespace, 670 | revision = self.revision 671 | ) 672 | 673 | async def refresh(self) -> ReleaseRevisionType: 674 | """ 675 | Returns a new revision representing the most recent state of this revision. 676 | """ 677 | return self.__class__._from_status( 678 | await self._command.status( 679 | self.release.name, 680 | namespace = self.release.namespace, 681 | revision = self.revision 682 | ), 683 | self._command 684 | ) 685 | 686 | async def diff( 687 | self, 688 | other_revision: int, 689 | *, 690 | # The number of lines of context to show around each diff 691 | context_lines: t.Optional[int] = None, 692 | # Indicates whether to show secret values in the diff 693 | show_secrets: bool = True 694 | ) -> str: 695 | """ 696 | Returns the diff between this revision and the specified revision. 697 | """ 698 | return await self._command.diff_revision( 699 | self.release.name, 700 | self.revision, 701 | other_revision, 702 | context_lines = context_lines, 703 | namespace = self.release.namespace, 704 | show_secrets = show_secrets 705 | ) 706 | 707 | @classmethod 708 | def _from_status(cls, status: t.Dict[str, t.Any], command: Command): 709 | """ 710 | Internal constructor to create a release revision from a status result. 711 | """ 712 | revision = ReleaseRevision( 713 | command, 714 | release = Release( 715 | command, 716 | name = status["name"], 717 | namespace = status["namespace"] 718 | ), 719 | revision = status["version"], 720 | status = status["info"]["status"], 721 | updated = status["info"]["last_deployed"], 722 | description = status["info"].get("description"), 723 | notes = status["info"].get("notes") 724 | ) 725 | revision._set_from_status(status) 726 | return revision 727 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pyhelm3 3 | description = Python library for managing Helm releases using Helm 3. 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown 6 | author = Matt Pryor 7 | author_email = matt@stackhpc.com 8 | url = https://github.com/azimuth-cloud/pyhelm3 9 | license_files = LICENSE 10 | 11 | [options] 12 | zip_safe = False 13 | include_package_data = True 14 | packages = find: 15 | install_requires = 16 | pydantic 17 | pyyaml 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import setuptools 4 | 5 | if __name__ == "__main__": 6 | setuptools.setup() 7 | --------------------------------------------------------------------------------