├── .flake8 ├── .github └── workflows │ ├── black.yml │ ├── deb.yml │ ├── flake8.yml │ └── pylint.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── README.md ├── bash_completion └── incant_completion.sh ├── debian ├── changelog ├── control ├── copyright ├── incant.bash-completion ├── incant.docs ├── incant.examples ├── rules └── source │ ├── format │ └── options ├── examples ├── advanced.yaml ├── basic.yaml ├── data │ ├── README.md │ └── images.json ├── debian_tester.yaml.j2 ├── provision │ └── web_server.rb └── public_images.yaml.mako ├── incant ├── __init__.py ├── cli.py ├── incant.py └── incus_cli.py ├── poetry.lock ├── pyproject.toml └── tests └── __init__.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 110 3 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Black 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: psf/black@stable 11 | -------------------------------------------------------------------------------- /.github/workflows/deb.yml: -------------------------------------------------------------------------------- 1 | name: Debian 2 | 3 | on: push 4 | 5 | jobs: 6 | build-debs: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: jtdor/build-deb-action@v1 11 | env: 12 | DEB_BUILD_OPTIONS: noautodbgsym 13 | with: 14 | buildpackage-opts: --no-sign 15 | -------------------------------------------------------------------------------- /.github/workflows/flake8.yml: -------------------------------------------------------------------------------- 1 | name: flake8 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | flake8-lint: 7 | runs-on: ubuntu-latest 8 | name: Lint 9 | steps: 10 | - name: Check out source repository 11 | uses: actions/checkout@v3 12 | - name: Set up Python environment 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.11" 16 | - name: flake8 Lint 17 | uses: py-actions/flake8@v2 18 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [push,pull_request] 4 | 5 | jobs: 6 | pylint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Python 11 | uses: actions/setup-python@v3 12 | with: 13 | python-version: 3.11 14 | - name: Install dependencies 15 | run: | 16 | set -x 17 | python -m pip install --upgrade pip 18 | pip install pylint poetry 19 | poetry install --no-interaction --no-root 20 | 21 | - name: Run pylint 22 | run: poetry run pylint incant/ 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # PyPI configuration file 171 | .pypirc 172 | 173 | # Debian packaging stuff 174 | .pybuild/ 175 | debian/.debhelper/ 176 | debian/debhelper-build-stamp 177 | debian/files 178 | debian/incant.postinst.debhelper 179 | debian/incant.prerm.debhelper 180 | debian/incant.substvars 181 | debian/incant/ 182 | 183 | incant.yaml 184 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=missing-docstring 3 | -------------------------------------------------------------------------------- /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 | # Incant 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/incus-incant.svg)](https://pypi.org/project/incus-incant/) 4 | 5 | Incant is a frontend for [Incus](https://linuxcontainers.org/incus/) that provides a declarative way to define and manage development environments. It simplifies the creation, configuration, and provisioning of Incus instances using YAML-based configuration files. 6 | 7 | ## Features 8 | 9 | - **Declarative Configuration**: Define your development environments using simple YAML files. 10 | - **Provisioning Support**: Declare and run provisioning scripts automatically. 11 | - **Shared Folder Support**: Mount the current working directory into the instance. 12 | 13 | ## Installation 14 | 15 | Ensure you have Python installed and `incus` available on your system. 16 | 17 | You can install Incant from PyPI: 18 | 19 | ```sh 20 | pipx install incus-incant 21 | ``` 22 | 23 | Or install directly from Git: 24 | 25 | ```sh 26 | pipx install git+https://github.com/lnussbaum/incant.git 27 | ``` 28 | 29 | ## Usage 30 | 31 | ## Configure Incant 32 | 33 | Incant looks for a configuration file named `incant.yaml`, `incant.yaml.j2`, or `incant.yaml.mako` in the current directory. Here is an example: 34 | 35 | ```yaml 36 | instances: 37 | my-instance: 38 | image: images:debian/12 39 | vm: false # use a container, not a KVM virtual machine 40 | provision: 41 | - echo "Hello, World!" 42 | - apt-get update && apt-get install -y curl 43 | ``` 44 | 45 | You can also ask Incant to create an example in the current directory: 46 | 47 | ```sh 48 | $ incant init 49 | ``` 50 | 51 | ### Initialize and Start an Instance 52 | 53 | ```sh 54 | $ incant up 55 | ``` 56 | 57 | or for a specific instance: 58 | 59 | ```sh 60 | $ incant up my-instance 61 | ``` 62 | 63 | ### Provision again an Instance that was already started previously 64 | 65 | ```sh 66 | $ incant provision 67 | ``` 68 | 69 | or for a specific instance: 70 | 71 | ```sh 72 | $ incant provision my-instance 73 | ``` 74 | 75 | ### Use your Instances 76 | 77 | Use [Incus commands](https://linuxcontainers.org/incus/docs/main/instances/) to interact with your instances: 78 | 79 | ```sh 80 | $ incus exec ubuntu-container -- apt-get update 81 | $ incus shell my-instance 82 | $ incus console my-instance 83 | $ incus file edit my-container/etc/hosts 84 | $ incus file delete / 85 | ``` 86 | 87 | Your instance's services are directly reachable on the network. They should be discoverable in DNS if the instance supports [LLMNR](https://en.wikipedia.org/wiki/Link-Local_Multicast_Name_Resolution) or [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS). 88 | 89 | ### Destroy an Instance 90 | 91 | ```sh 92 | $ incant destroy 93 | ``` 94 | 95 | or for a specific instance: 96 | 97 | ```sh 98 | $ incant destroy my-instance 99 | ``` 100 | 101 | ### View Configuration (especially useful if you use Mako or Jinja2 templates) 102 | 103 | ```sh 104 | $ incant dump 105 | ``` 106 | 107 | ## Incant compared to Vagrant 108 | 109 | Incant is inspired by Vagrant, and intended as an Incus-based replacement for Vagrant. 110 | 111 | The main differences between Incant and Vagrant are: 112 | 113 | * Incant is Free Software (licensed under the Apache 2.0 license). Vagrant is licensed under the non-Open-Source Business Source License. 114 | * Incant is only a frontend for [Incus](https://linuxcontainers.org/incus/), which supports containers (LXC-based) and virtual machines (KVM-based) on Linux. It will not attempt to be a more generic frontend for other virtualization providers. Thus, Incant only works on Linux. 115 | 116 | Some technical differences are useful to keep in mind when migrating from Vagrant to Incant. 117 | 118 | * Incant is intended as a thin layer on top of Incus, and focuses on provisioning. Once the provisioning has been performed by Incant, you need to use Incus commands such as `incus shell` to work with your instances. 119 | * Incant shares the current directory as `/incant` inside the instance (compared to Vagrant's sharing of `/vagrant`). Incant tries to share the current directory read-write (using Incus' `shift=true`) but this fails in some cases, such as restricted containers. So there are chances that the directory will only be shared read-only. 120 | * Incant does not create a user account inside the instance -- you need to use the root account, or create a user account during provisioning (for example, with `adduser --disabled-password --gecos "" incant`) 121 | * Incant uses a YAML-based description format for instances. [Mako](https://www.makotemplates.org/) or [Jinja2](https://jinja.palletsprojects.com/) templates can be used to those YAML configuration files if you need more complex processing, similar to what is available in *Vagrantfiles* (see the examples/ directory). 122 | 123 | ## Incant compared to other projects 124 | 125 | There are several other projects addressing similar problem spaces. They are shortly described here so that you can determine if Incant is the right tool for you. 126 | 127 | * [lxops](https://github.com/melato/lxops) and [blincus](https://blincus.dev/) manage the provisioning of Incus instances using a declarative configuration format, but the provisioning actions are described using [cloud-init](https://cloud-init.io/) configuration files. [lxops](https://github.com/melato/lxops) uses [cloudconfig](https://github.com/melato/cloudconfig) to apply them, while [blincus](https://blincus.dev/) requires *cloud* instances that include cloud-init. In contrast, using Incant does not require knowing about cloud-init or fitting into cloud-init's formalism. 128 | * [terraform-provider-incus](https://github.com/lxc/terraform-provider-incus) is a [Terraform](https://www.terraform.io/) or [OpenTofu](https://opentofu.org/) provider for Incus. Incant uses a more basic scheme for provisioning, and does not require knowing about Terraform or fitting into Terraform's formalism. 129 | * [cluster-api-provider-lxc (CAPL)](https://github.com/neoaggelos/cluster-api-provider-lxc) is an infrastructure provider for Kubernetes' Cluster API, which enables deploying Kubernetes clusters on Incus. Incant focuses on the more general use case of provisioning system containers or virtual machines outside of the Kubernetes world. 130 | * [devenv](https://devenv.sh/) is a [Nix](https://nixos.org/)-based development environment manager. It also uses a declarative file format. It goes further than Incant by including the definition of development tasks. It also covers defining services that run inside the environment, and generating OCI containers to deploy the environment to production. Incant focuses on providing the environment based on classical Linux distributions and tools. 131 | 132 | ## License 133 | 134 | This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for details. 135 | 136 | -------------------------------------------------------------------------------- /bash_completion/incant_completion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This is the Bash completion script for Incant 4 | 5 | # _incant_completions: Provides autocompletion for Incant commands 6 | _incant_completions() { 7 | local cur prev opts instance_names 8 | COMPREPLY=() 9 | cur="${COMP_WORDS[COMP_CWORD]}" 10 | prev="${COMP_WORDS[COMP_CWORD-1]}" 11 | 12 | # Commands like up, provision, destroy, list, dump 13 | opts="up provision destroy list dump" 14 | 15 | 16 | # Handle auto-completion based on the previous command word 17 | case "${prev}" in 18 | "up"|"provision"|"destroy") 19 | # Fetch list of instances from the 'incant list' command 20 | instance_names=$(incant --quiet list) 21 | if [ $? -ne 0 ]; then 22 | COMPREPLY=( $(compgen -W "" -- ${cur}) ) 23 | return 0 24 | fi 25 | instance_names=$(echo $instance_names | tr '\n' ' ') # Convert the list to a space-separated string 26 | # Complete with instance names 27 | COMPREPLY=( $(compgen -W "${instance_names}" -- ${cur}) ) 28 | return 0 29 | ;; 30 | "list"|"dump") 31 | # No further completion needed for 'list' or 'dump' 32 | return 0 33 | ;; 34 | *) 35 | # Complete with the general commands (up, provision, etc.) 36 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 37 | return 0 38 | ;; 39 | esac 40 | } 41 | 42 | # Enable completion for incant 43 | complete -F _incant_completions incant 44 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | incant (0.1) UNRELEASED; urgency=medium 2 | 3 | * Initial release. (Closes: #nnnn) 4 | 5 | -- Lucas Nussbaum Tue, 14 Jan 2025 21:26:16 +0100 6 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: incant 2 | Section: admin 3 | Priority: optional 4 | Maintainer: Lucas Nussbaum 5 | Build-Depends: bash-completion, 6 | debhelper-compat (= 13), 7 | dh-sequence-python3, 8 | pybuild-plugin-pyproject, 9 | python3-all, 10 | python3-click, 11 | python3-jinja2, 12 | python3-mako, 13 | python3-poetry-core, 14 | python3-setuptools, 15 | python3-yaml 16 | Testsuite: autopkgtest-pkg-python 17 | Standards-Version: 4.6.2 18 | Homepage: https://github.com/lnussbaum/incant 19 | Vcs-Browser: https://salsa.debian.org/debian/incant 20 | Vcs-Git: https://salsa.debian.org/debian/incant.git 21 | 22 | Package: incant 23 | Architecture: all 24 | Depends: ${misc:Depends}, 25 | ${python3:Depends} 26 | Description: Incant 27 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Source: 3 | Upstream-Name: incant 4 | Upstream-Contact: 5 | 6 | Files: 7 | * 8 | Copyright: 9 | 10 | 11 | License: 12 | 13 | 14 | . 15 | 16 | 17 | # If you want to use GPL v2 or later for the /debian/* files use 18 | # the following clauses, or change it to suit. Delete these two lines 19 | Files: 20 | debian/* 21 | Copyright: 22 | 2025 Lucas Nussbaum 23 | License: GPL-2+ 24 | This package is free software; you can redistribute it and/or modify 25 | it under the terms of the GNU General Public License as published by 26 | the Free Software Foundation; either version 2 of the License, or 27 | (at your option) any later version. 28 | . 29 | This package is distributed in the hope that it will be useful, 30 | but WITHOUT ANY WARRANTY; without even the implied warranty of 31 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 32 | GNU General Public License for more details. 33 | . 34 | You should have received a copy of the GNU General Public License 35 | along with this program. If not, see 36 | Comment: 37 | On Debian systems, the complete text of the GNU General 38 | Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". 39 | 40 | # Please also look if there are files or directories which have a 41 | # different copyright/license attached and list them here. 42 | # Please avoid picking licenses with terms that are more restrictive than the 43 | # packaged work, as it may make Debian's contributions unacceptable upstream. 44 | # 45 | # If you need, there are some extra license texts available in two places: 46 | # /usr/share/debhelper/dh_make/licenses/ 47 | # /usr/share/common-licenses/ 48 | -------------------------------------------------------------------------------- /debian/incant.bash-completion: -------------------------------------------------------------------------------- 1 | bash_completion/incant_completion.sh incant 2 | -------------------------------------------------------------------------------- /debian/incant.docs: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /debian/incant.examples: -------------------------------------------------------------------------------- 1 | examples/* 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | export DH_VERBOSE = 1 4 | export PYBUILD_NAME=incant 5 | 6 | %: 7 | dh $@ --with python3 --buildsystem=pybuild --with bash-completion 8 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /debian/source/options: -------------------------------------------------------------------------------- 1 | extend-diff-ignore = "^[^/]*[.]egg-info/" 2 | -------------------------------------------------------------------------------- /examples/advanced.yaml: -------------------------------------------------------------------------------- 1 | instances: 2 | deb11: 3 | image: images:debian/11 4 | vm: true 5 | wait: true # wait for instance to be ready (incus agent running) 6 | deb10: 7 | image: images:debian/10 8 | provision: | 9 | #!/bin/bash 10 | set -xe 11 | apt-get update 12 | apt-get -y install curl 13 | webserver: 14 | image: images:debian/12 15 | # Let's use a more complex provisionning here. 16 | vm: true 17 | profiles: 18 | - default 19 | config: 20 | limits.processes: 100 21 | devices: 22 | root: 23 | size: 20GB 24 | type: aws:t2.micro 25 | provision: 26 | # first, a single command 27 | - apt-get update && apt-get -y install ruby 28 | # then, a script. the path can be relative to the current dir, 29 | # as incant will 'cd' to /incant 30 | - examples/provision/web_server.rb 31 | # then a multi-line snippet that will be copied as a temporary file 32 | - | 33 | #!/bin/bash 34 | set -xe 35 | echo Done! 36 | -------------------------------------------------------------------------------- /examples/basic.yaml: -------------------------------------------------------------------------------- 1 | instances: 2 | client: 3 | image: images:ubuntu/24.04 4 | provision: | 5 | #!/bin/bash 6 | set -xe 7 | apt-get update 8 | apt-get -y install curl 9 | webserver: 10 | image: images:debian/12 11 | vm: true # KVM virtual machine, not container 12 | # Let's use a more complex provisionning here. 13 | devices: 14 | root: 15 | size: 20GB # set size of root device to 20GB 16 | config: # incus config options 17 | limits.processes: 100 18 | type: c2-m2 # 2 CPUs, 2 GB of RAM 19 | provision: 20 | # first, a single command 21 | - apt-get update && apt-get -y install ruby 22 | # then, a script. the path can be relative to the current dir, 23 | # as incant will 'cd' to /incant 24 | - examples/provision/web_server.rb 25 | # then a multi-line snippet that will be copied as a temporary file 26 | - | 27 | #!/bin/bash 28 | set -xe 29 | echo Done! 30 | -------------------------------------------------------------------------------- /examples/data/README.md: -------------------------------------------------------------------------------- 1 | refresh with: `incus image list images: --format=json | jq . > images.json` 2 | -------------------------------------------------------------------------------- /examples/debian_tester.yaml.j2: -------------------------------------------------------------------------------- 1 | instances: 2 | {% for version in ['13', '12', '11', '10'] %} 3 | deb{{ version }}: 4 | image: images:debian/{{ version }} 5 | {% endfor %} 6 | -------------------------------------------------------------------------------- /examples/provision/web_server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby -w 2 | system("apt-get -y install apache2") or raise 3 | -------------------------------------------------------------------------------- /examples/public_images.yaml.mako: -------------------------------------------------------------------------------- 1 | <% 2 | import json 3 | import platform 4 | import re 5 | 6 | # Load images from examples/data/images.json file 7 | with open("examples/data/images.json", "r", encoding="utf-8") as f: 8 | images = json.load(f) 9 | 10 | # Determine current architecture 11 | current_arch = platform.machine() 12 | 13 | # Function to sanitize instance names 14 | def sanitize_name(name): 15 | # Convert to lowercase, replace non-alphanumeric characters with hyphens 16 | return re.sub(r'[^a-z0-9-]', '-', name.lower()) 17 | 18 | # Filter images for foreign architectures 19 | filtered_images = [ 20 | img for img in images 21 | if img.get("architecture") == current_arch and img.get("aliases") 22 | ] 23 | 24 | # Function to get the shortest alias name 25 | def get_shortest_alias(aliases): 26 | return min(aliases, key=lambda alias: len(alias["name"]))["name"] 27 | %> 28 | 29 | instances: 30 | % for img in filtered_images: 31 | <% vm = img['type'] == 'virtual-machine' %> 32 | ${sanitize_name(get_shortest_alias(img["aliases"]))}${'-vm' if vm else ''}: 33 | image: images:${get_shortest_alias(img["aliases"])} 34 | vm: ${"true" if vm else "false"} 35 | wait: true 36 | % endfor 37 | -------------------------------------------------------------------------------- /incant/__init__.py: -------------------------------------------------------------------------------- 1 | from .incant import Incant # noqa: F401 2 | from .incus_cli import IncusCLI # noqa: F401 3 | -------------------------------------------------------------------------------- /incant/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | from incant import Incant 3 | 4 | 5 | @click.group(invoke_without_command=True) 6 | @click.option("-v", "--verbose", is_flag=True, help="Enable verbose mode.") 7 | @click.option("-f", "--config", type=click.Path(exists=True), help="Path to configuration file.") 8 | @click.option( 9 | "-q", "--quiet", is_flag=True, help="Do not display error message if no config file found." 10 | ) 11 | @click.pass_context 12 | def cli(ctx, verbose, config, quiet): 13 | """Incant -- an Incus frontend for declarative development environments""" 14 | ctx.ensure_object(dict) 15 | ctx.obj["OPTIONS"] = {"verbose": verbose, "config": config, "quiet": quiet} 16 | if verbose: 17 | click.echo( 18 | f"Using config file: {config}" if config else "No config file provided, using defaults." 19 | ) 20 | if ctx.invoked_subcommand is None: 21 | click.echo(ctx.get_help()) # Show help message if no command is passed 22 | 23 | 24 | @cli.command() 25 | @click.argument("name", required=False) 26 | @click.pass_context 27 | def up(ctx, name: str): 28 | """Start and provision an instance or all instances if no name is provided.""" 29 | inc = Incant(**ctx.obj["OPTIONS"]) 30 | inc.up(name) 31 | 32 | 33 | @cli.command() 34 | @click.argument("name", required=False) 35 | @click.pass_context 36 | def provision(ctx, name: str = None): 37 | """Provision an instance or all instances if no name is provided.""" 38 | inc = Incant(**ctx.obj["OPTIONS"]) 39 | inc.provision(name) 40 | 41 | 42 | @cli.command() 43 | @click.argument("name", required=False) 44 | @click.pass_context 45 | def destroy(ctx, name: str): 46 | """Destroy an instance or all instances if no name is provided.""" 47 | inc = Incant(**ctx.obj["OPTIONS"]) 48 | inc.destroy(name) 49 | 50 | 51 | @cli.command() 52 | @click.pass_context 53 | def dump(ctx): 54 | """Show the generated configuration file.""" 55 | inc = Incant(**ctx.obj["OPTIONS"]) 56 | inc.dump_config() 57 | 58 | 59 | @cli.command() 60 | @click.pass_context 61 | def list(ctx): 62 | """List all instances defined in the configuration.""" 63 | inc = Incant(**ctx.obj["OPTIONS"]) 64 | inc.list_instances() 65 | 66 | 67 | @cli.command() 68 | @click.pass_context 69 | def init(ctx): 70 | """Create an example configuration file in the current directory.""" 71 | inc = Incant(**ctx.obj["OPTIONS"], no_config=True) 72 | inc.incant_init() 73 | -------------------------------------------------------------------------------- /incant/incant.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import textwrap 5 | from pathlib import Path 6 | import click 7 | import yaml 8 | from jinja2 import Environment, FileSystemLoader 9 | from mako.template import Template 10 | from incant.incus_cli import IncusCLI 11 | 12 | # click output styles 13 | CLICK_STYLE = { 14 | "success": {"fg": "green", "bold": True}, 15 | "info": {"fg": "cyan"}, 16 | "warning": {"fg": "yellow"}, 17 | "error": {"fg": "red"}, 18 | } 19 | 20 | 21 | class Incant: 22 | def __init__(self, **kwargs): 23 | self.verbose = kwargs.get("verbose", False) 24 | self.config = kwargs.get("config", None) 25 | self.quiet = kwargs.get("quiet", False) 26 | self.no_config = kwargs.get("no_config", False) 27 | if self.no_config: 28 | self.config_data = None 29 | else: 30 | self.config_data = self.load_config() 31 | 32 | def find_config_file(self): 33 | config_paths = [ 34 | ( 35 | Path(self.config) if self.config else None 36 | ), # First, check if a config is passed directly 37 | *( 38 | Path(os.getcwd()) / f"incant{ext}" 39 | for ext in [ 40 | ".yaml", 41 | ".yaml.j2", 42 | ".yaml.mako", 43 | ] 44 | ), 45 | *( 46 | Path(os.getcwd()) / f".incant{ext}" 47 | for ext in [ 48 | ".yaml", 49 | ".yaml.j2", 50 | ".yaml.mako", 51 | ] 52 | ), 53 | ] 54 | for path in filter(None, config_paths): 55 | if path.is_file(): 56 | if self.verbose: 57 | click.secho(f"Config found at: {path}", **CLICK_STYLE["success"]) 58 | return path 59 | # If no config is found, return None 60 | return None 61 | 62 | def load_config(self): 63 | try: 64 | # Find the config file first 65 | config_file = self.find_config_file() 66 | 67 | if config_file is None: 68 | if not self.quiet: 69 | click.secho("No config file found to load.", **CLICK_STYLE["error"]) 70 | return None 71 | 72 | # Read the config file content 73 | with open(config_file, "r", encoding="utf-8") as file: 74 | content = file.read() 75 | 76 | # If the config file ends with .yaml.j2, use Jinja2 77 | if config_file.suffix == ".j2": 78 | if self.verbose: 79 | click.secho("Using Jinja2 template processing...", **CLICK_STYLE["info"]) 80 | env = Environment(loader=FileSystemLoader(os.getcwd())) 81 | template = env.from_string(content) 82 | content = template.render() 83 | 84 | # If the config file ends with .yaml.mako, use Mako 85 | elif config_file.suffix == ".mako": 86 | if self.verbose: 87 | click.secho("Using Mako template processing...", **CLICK_STYLE["info"]) 88 | template = Template(content) 89 | content = template.render() 90 | 91 | # Load the YAML data from the processed content 92 | config_data = yaml.safe_load(content) 93 | 94 | if self.verbose: 95 | click.secho( 96 | f"Config loaded successfully from {config_file}", 97 | **CLICK_STYLE["success"], 98 | ) 99 | return config_data 100 | except yaml.YAMLError as e: 101 | click.secho(f"Error parsing YAML file: {e}", **CLICK_STYLE["error"]) 102 | return None 103 | except FileNotFoundError: 104 | click.secho(f"Config file not found: {config_file}", **CLICK_STYLE["error"]) 105 | sys.exit(1) 106 | 107 | def dump_config(self): 108 | if not self.config_data: 109 | sys.exit(1) 110 | try: 111 | yaml.dump(self.config_data, sys.stdout, default_flow_style=False, sort_keys=False) 112 | except Exception as e: 113 | click.secho(f"Error dumping configuration: {e}", **CLICK_STYLE["error"]) 114 | 115 | def check_config(self): 116 | if not self.config_data: 117 | sys.exit(1) 118 | if "instances" not in self.config_data: 119 | click.secho("No instances found in config.", **CLICK_STYLE["error"]) 120 | sys.exit(1) 121 | 122 | def up(self, name=None): 123 | self.check_config() 124 | 125 | incus = IncusCLI() 126 | 127 | # If a name is provided, check if the instance exists in the config 128 | if name and name not in self.config_data["instances"]: 129 | click.secho(f"Instance '{name}' not found in config.", **CLICK_STYLE["error"]) 130 | return 131 | 132 | # Step 1 -- Create instances (we do this for all instances so that they can boot in parallel) 133 | # Loop through all instances, but skip those that don't match the provided name (if any) 134 | for instance_name, instance_data in self.config_data["instances"].items(): 135 | # If a name is provided, only process the matching instance 136 | if name and instance_name != name: 137 | continue 138 | 139 | # Process the instance 140 | image = instance_data.get("image") 141 | if not image: 142 | click.secho(f"Skipping {instance_name}: No image defined.", **CLICK_STYLE["error"]) 143 | continue 144 | 145 | vm = instance_data.get("vm", False) 146 | profiles = instance_data.get("profiles", None) 147 | config = instance_data.get("config", None) 148 | devices = instance_data.get("devices", None) 149 | network = instance_data.get("network", None) 150 | instance_type = instance_data.get("type", None) 151 | 152 | click.secho( 153 | f"Creating instance {instance_name} with image {image}...", 154 | **CLICK_STYLE["success"], 155 | ) 156 | incus.create_instance( 157 | instance_name, 158 | image, 159 | profiles=profiles, 160 | vm=vm, 161 | config=config, 162 | devices=devices, 163 | network=network, 164 | instance_type=instance_type, 165 | ) 166 | 167 | # Step 2 -- Create shared folder and provision 168 | # Loop through all instances, but skip those that don't match the provided name (if any) 169 | for instance_name, instance_data in self.config_data["instances"].items(): 170 | # If a name is provided, only process the matching instance 171 | if name and instance_name != name: 172 | continue 173 | 174 | # Wait for the agent to become ready before sharing the current directory 175 | while True: 176 | if incus.is_agent_running(instance_name) and incus.is_agent_usable(instance_name): 177 | break 178 | time.sleep(0.3) 179 | click.secho( 180 | f"Sharing current directory to {instance_name}:/incant ...", 181 | **CLICK_STYLE["success"], 182 | ) 183 | 184 | # Wait for the instance to become ready if specified in config, or 185 | # we want to perform provisioning, or the instance is a VM (for some 186 | # reason the VM needs to be running before creating the shared folder) 187 | if ( 188 | instance_data.get("wait", False) 189 | or instance_data.get("provision", False) 190 | or instance_data.get("vm", False) 191 | ): 192 | click.secho( 193 | f"Waiting for {instance_name} to become ready...", 194 | **CLICK_STYLE["info"], 195 | ) 196 | while True: 197 | if incus.is_instance_ready(instance_name, True): 198 | click.secho( 199 | f"Instance {instance_name} is ready.", 200 | **CLICK_STYLE["success"], 201 | ) 202 | break 203 | time.sleep(1) 204 | 205 | incus.create_shared_folder(instance_name) 206 | 207 | if instance_data.get("provision", False): 208 | # Automatically run provisioning after instance creation 209 | self.provision(instance_name) 210 | 211 | def provision(self, name: str = None): 212 | self.check_config() 213 | 214 | incus = IncusCLI() 215 | 216 | if name: 217 | # If a specific instance name is provided, check if it exists 218 | if name not in self.config_data["instances"]: 219 | click.echo(f"Instance '{name}' not found in config.") 220 | return 221 | instances_to_provision = {name: self.config_data["instances"][name]} 222 | else: 223 | # If no name is provided, provision all instances 224 | instances_to_provision = self.config_data["instances"] 225 | 226 | for instance_name, instance_data in instances_to_provision.items(): 227 | provisions = instance_data.get("provision", []) 228 | 229 | if not provisions: 230 | click.secho(f"No provisioning found for {instance_name}.", **CLICK_STYLE["info"]) 231 | continue 232 | 233 | click.secho(f"Provisioning instance {instance_name}...", **CLICK_STYLE["success"]) 234 | 235 | # Handle provisioning steps 236 | if isinstance(provisions, str): 237 | incus.provision(instance_name, provisions) 238 | elif isinstance(provisions, list): 239 | for step in provisions: 240 | click.secho("Running provisioning step ...", **CLICK_STYLE["info"]) 241 | incus.provision(instance_name, step) 242 | 243 | def destroy(self, name=None): 244 | self.check_config() 245 | 246 | incus = IncusCLI() 247 | 248 | # If a name is provided, check if the instance exists in the config 249 | if name and name not in self.config_data["instances"]: 250 | click.secho(f"Instance '{name}' not found in config.", **CLICK_STYLE["error"]) 251 | return 252 | 253 | for instance_name, _instance_data in self.config_data["instances"].items(): 254 | # If a name is provided, only process the matching instance 255 | if name and instance_name != name: 256 | continue 257 | 258 | # Check if the instance exists before deleting 259 | if not incus.is_instance(instance_name): 260 | click.secho(f"Instance '{instance_name}' does not exist.", **CLICK_STYLE["info"]) 261 | continue 262 | 263 | click.secho(f"Destroying instance {instance_name} ...", **CLICK_STYLE["success"]) 264 | incus.destroy_instance(instance_name) 265 | 266 | def list_instances(self): 267 | """List all instances defined in the configuration.""" 268 | self.check_config() 269 | 270 | for instance_name in self.config_data["instances"]: 271 | click.echo(f"{instance_name}") 272 | 273 | def incant_init(self): 274 | example_config = textwrap.dedent( 275 | """\ 276 | instances: 277 | client: 278 | image: images:ubuntu/24.04 279 | provision: | 280 | #!/bin/bash 281 | set -xe 282 | apt-get update 283 | apt-get -y install curl 284 | webserver: 285 | image: images:debian/13 286 | vm: true # KVM virtual machine, not container 287 | # Let's use a more complex provisionning here. 288 | devices: 289 | root: 290 | size: 20GB # set size of root device to 20GB 291 | config: # incus config options 292 | limits.processes: 100 293 | type: c2-m2 # 2 CPUs, 2 GB of RAM 294 | provision: 295 | # first, a single command 296 | - apt-get update && apt-get -y install ruby 297 | # then, a script. the path can be relative to the current dir, 298 | # as incant will 'cd' to /incant 299 | # - examples/provision/web_server.rb # disabled to provide a working example 300 | # then a multi-line snippet that will be copied as a temporary file 301 | - | 302 | #!/bin/bash 303 | set -xe 304 | echo Done! 305 | """ 306 | ) 307 | 308 | config_path = "incant.yaml" 309 | 310 | if os.path.exists(config_path): 311 | print(f"{config_path} already exists. Aborting.") 312 | sys.exit(1) 313 | 314 | with open(config_path, "w") as f: 315 | f.write(example_config) 316 | 317 | print(f"Example configuration written to {config_path}") 318 | -------------------------------------------------------------------------------- /incant/incus_cli.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import json 3 | from typing import List, Dict, Optional 4 | import sys 5 | import tempfile 6 | import os 7 | from pathlib import Path 8 | import click 9 | 10 | # click output styles 11 | CLICK_STYLE = { 12 | "success": {"fg": "green", "bold": True}, 13 | "info": {"fg": "cyan"}, 14 | "warning": {"fg": "yellow"}, 15 | "error": {"fg": "red"}, 16 | } 17 | 18 | 19 | class IncusCLI: 20 | """ 21 | A Python wrapper for the Incus CLI interface. 22 | """ 23 | 24 | def __init__(self, incus_cmd: str = "incus"): 25 | self.incus_cmd = incus_cmd 26 | 27 | def _run_command( 28 | self, 29 | command: List[str], 30 | *, 31 | capture_output: bool = True, 32 | allow_failure: bool = False, 33 | exception_on_failure: bool = False, 34 | quiet: bool = False, 35 | ) -> str: 36 | """Executes an Incus CLI command and returns the output. Optionally allows failure.""" 37 | try: 38 | full_command = [self.incus_cmd] + command 39 | if not quiet: 40 | click.secho(f"-> {' '.join(full_command)}", **CLICK_STYLE["info"]) 41 | result = subprocess.run( 42 | full_command, capture_output=capture_output, text=True, check=True 43 | ) 44 | return result.stdout 45 | except subprocess.CalledProcessError as e: 46 | error_message = f"Failed: {e.stderr.strip()}" if capture_output else "Command failed" 47 | if allow_failure: 48 | click.secho(error_message, **CLICK_STYLE["error"]) 49 | return "" 50 | elif exception_on_failure: 51 | raise 52 | else: 53 | click.secho(error_message, **CLICK_STYLE["error"]) 54 | sys.exit(1) 55 | 56 | def exec(self, name: str, command: List[str], cwd: str = None, **kwargs) -> str: 57 | cmd = ["exec"] 58 | if cwd: 59 | cmd.extend(["--cwd", cwd]) 60 | cmd.extend([name, "--"] + command) 61 | return self._run_command(cmd, **kwargs) 62 | 63 | def create_project(self, name: str) -> None: 64 | """Creates a new project.""" 65 | command = ["project", "create", name] 66 | self._run_command(command) 67 | 68 | def create_instance( 69 | self, 70 | name: str, 71 | image: str, 72 | profiles: Optional[List[str]] = None, 73 | vm: bool = False, 74 | config: Optional[Dict[str, str]] = None, 75 | devices: Optional[Dict[str, Dict[str, str]]] = None, 76 | network: Optional[str] = None, 77 | instance_type: Optional[str] = None, 78 | ) -> None: 79 | """Creates a new instance with optional parameters.""" 80 | command = ["launch", image, name] 81 | 82 | if vm: 83 | command.append("--vm") 84 | 85 | if profiles: 86 | for profile in profiles: 87 | command.extend(["--profile", profile]) 88 | 89 | if config: 90 | for key, value in config.items(): 91 | command.extend(["--config", f"{key}={value}"]) 92 | 93 | if devices: 94 | for dev_name, dev_attrs in devices.items(): 95 | dev_str = f"{dev_name}" 96 | for k, v in dev_attrs.items(): 97 | dev_str += f",{k}={v}" 98 | command.extend(["--device", dev_str]) 99 | 100 | if network: 101 | command.extend(["--network", network]) 102 | 103 | if instance_type: 104 | command.extend(["--type", instance_type]) 105 | 106 | self._run_command(command) 107 | 108 | def create_shared_folder(self, name: str) -> None: 109 | curdir = Path.cwd() 110 | command = [ 111 | "config", 112 | "device", 113 | "add", 114 | name, 115 | f"{name}_shared_incant", 116 | "disk", 117 | f"source={curdir}", 118 | "path=/incant", 119 | "shift=true", # First attempt with shift enabled 120 | ] 121 | 122 | try: 123 | self._run_command(command, exception_on_failure=True, capture_output=False) 124 | except subprocess.CalledProcessError: 125 | click.secho( 126 | "Shared folder creation failed. Retrying without shift=true...", 127 | **CLICK_STYLE["warning"], 128 | ) 129 | command.remove("shift=true") # Remove shift option and retry 130 | self._run_command(command, capture_output=False) 131 | 132 | # Sometimes the creation of shared directories fails (see https://github.com/lxc/incus/issues/1881) 133 | # So we retry up to 10 times 134 | for attempt in range(10): 135 | try: 136 | self.exec( 137 | name, 138 | ["grep", "-wq", "/incant", "/proc/mounts"], 139 | exception_on_failure=True, 140 | capture_output=False, 141 | ) 142 | return True 143 | except subprocess.CalledProcessError: 144 | click.secho( 145 | "Shared folder creation failed (/incant not mounted). Retrying...", 146 | **CLICK_STYLE["warning"], 147 | ) 148 | self._run_command( 149 | ["config", "device", "remove", name, f"{name}_shared_incant"], 150 | capture_output=False, 151 | ) 152 | self._run_command(command, capture_output=False) 153 | 154 | raise Exception("Shared folder creation failed.") 155 | 156 | def destroy_instance(self, name: str) -> None: 157 | """Destroy (stop if needed, then delete) an instance.""" 158 | self._run_command(["delete", "--force", name], allow_failure=True) 159 | 160 | def get_current_project(self) -> str: 161 | return self._run_command(["project", "get-current"], quiet=True).strip() 162 | 163 | def get_instance_info(self, name: str) -> Dict: 164 | """Gets detailed information about an instance.""" 165 | output = self._run_command( 166 | [ 167 | "query", 168 | f"/1.0/instances/{name}?project={self.get_current_project()}&recursion=1", 169 | ], 170 | quiet=True, 171 | exception_on_failure=True, 172 | ) 173 | return json.loads(output) 174 | 175 | def is_instance_stopped(self, name: str) -> bool: 176 | return self.get_instance_info(name)["status"] == "Stopped" 177 | 178 | def is_agent_running(self, name: str) -> bool: 179 | return self.get_instance_info(name).get("state", {}).get("processes", -2) > 0 180 | 181 | def is_agent_usable(self, name: str) -> bool: 182 | try: 183 | self.exec(name, ["true"], exception_on_failure=True, quiet=True) 184 | return True 185 | except subprocess.CalledProcessError as e: 186 | if e.stderr.strip() == "Error: VM agent isn't currently running": 187 | return False 188 | else: 189 | raise 190 | 191 | def is_instance_booted(self, name: str) -> bool: 192 | try: 193 | self.exec(name, ["which", "systemctl"], quiet=True, exception_on_failure=True) 194 | except Exception as exc: 195 | # no systemctl in instance. We assume it booted 196 | # return True 197 | raise RuntimeError("systemctl not found in instance") from exc 198 | try: 199 | systemctl = self.exec( 200 | name, 201 | ["systemctl", "is-system-running"], 202 | quiet=True, 203 | exception_on_failure=True, 204 | ).strip() 205 | except subprocess.CalledProcessError: 206 | return False 207 | return systemctl == "running" 208 | 209 | def is_instance_ready(self, name: str, verbose: bool = False) -> bool: 210 | if not self.is_agent_running(name): 211 | return False 212 | if verbose: 213 | click.secho("Agent is running, testing if usable...", **CLICK_STYLE["info"]) 214 | if not self.is_agent_usable(name): 215 | return False 216 | if verbose: 217 | click.secho("Agent is usable, checking if system booted...", **CLICK_STYLE["info"]) 218 | if not self.is_instance_booted(name): 219 | return False 220 | return True 221 | 222 | def is_instance(self, name: str) -> bool: 223 | """Checks if an instance exists.""" 224 | try: 225 | self.get_instance_info(name) 226 | return True 227 | except subprocess.CalledProcessError: 228 | return False 229 | 230 | def provision(self, name: str, provision: str, quiet: bool = True) -> None: 231 | """Provision an instance with a single command or a multi-line script.""" 232 | 233 | if "\n" not in provision: # Single-line command 234 | # Change to /incant and then execute the provision command inside 235 | # sh -c for quoting safety 236 | self.exec( 237 | name, 238 | ["sh", "-c", provision], 239 | quiet=quiet, 240 | capture_output=False, 241 | cwd="/incant", 242 | ) 243 | else: # Multi-line script 244 | # Create a secure temporary file locally 245 | fd, temp_path = tempfile.mkstemp(prefix="incant_") 246 | 247 | try: 248 | # Write the script content to the temporary file 249 | with os.fdopen(fd, "w") as temp_file: 250 | temp_file.write(provision) 251 | 252 | # Copy the file to the instance 253 | self._run_command(["file", "push", temp_path, f"{name}{temp_path}"], quiet=quiet) 254 | 255 | # Execute the script after copying 256 | self.exec( 257 | name, 258 | [ 259 | "sh", 260 | "-c", 261 | f"chmod +x {temp_path} && {temp_path} && rm {temp_path}", 262 | ], 263 | quiet=quiet, 264 | capture_output=False, 265 | ) 266 | finally: 267 | # Clean up the local temporary file 268 | os.remove(temp_path) 269 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "astroid" 5 | version = "3.3.8" 6 | description = "An abstract syntax tree for Python with inference support." 7 | category = "dev" 8 | optional = false 9 | python-versions = ">=3.9.0" 10 | files = [ 11 | {file = "astroid-3.3.8-py3-none-any.whl", hash = "sha256:187ccc0c248bfbba564826c26f070494f7bc964fd286b6d9fff4420e55de828c"}, 12 | {file = "astroid-3.3.8.tar.gz", hash = "sha256:a88c7994f914a4ea8572fac479459f4955eeccc877be3f2d959a33273b0cf40b"}, 13 | ] 14 | 15 | [[package]] 16 | name = "click" 17 | version = "8.1.8" 18 | description = "Composable command line interface toolkit" 19 | category = "main" 20 | optional = false 21 | python-versions = ">=3.7" 22 | files = [ 23 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 24 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 25 | ] 26 | 27 | [package.dependencies] 28 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 29 | 30 | [[package]] 31 | name = "colorama" 32 | version = "0.4.6" 33 | description = "Cross-platform colored terminal text." 34 | category = "main" 35 | optional = false 36 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 37 | files = [ 38 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 39 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 40 | ] 41 | 42 | [[package]] 43 | name = "dill" 44 | version = "0.3.9" 45 | description = "serialize all of Python" 46 | category = "dev" 47 | optional = false 48 | python-versions = ">=3.8" 49 | files = [ 50 | {file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"}, 51 | {file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"}, 52 | ] 53 | 54 | [package.extras] 55 | graph = ["objgraph (>=1.7.2)"] 56 | profile = ["gprof2dot (>=2022.7.29)"] 57 | 58 | [[package]] 59 | name = "isort" 60 | version = "6.0.1" 61 | description = "A Python utility / library to sort Python imports." 62 | category = "dev" 63 | optional = false 64 | python-versions = ">=3.9.0" 65 | files = [ 66 | {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, 67 | {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, 68 | ] 69 | 70 | [package.extras] 71 | colors = ["colorama"] 72 | plugins = ["setuptools"] 73 | 74 | [[package]] 75 | name = "jinja2" 76 | version = "3.1.6" 77 | description = "A very fast and expressive template engine." 78 | category = "main" 79 | optional = false 80 | python-versions = ">=3.7" 81 | files = [ 82 | {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, 83 | {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, 84 | ] 85 | 86 | [package.dependencies] 87 | MarkupSafe = ">=2.0" 88 | 89 | [package.extras] 90 | i18n = ["Babel (>=2.7)"] 91 | 92 | [[package]] 93 | name = "mako" 94 | version = "1.3.9" 95 | description = "A super-fast templating language that borrows the best ideas from the existing templating languages." 96 | category = "main" 97 | optional = false 98 | python-versions = ">=3.8" 99 | files = [ 100 | {file = "Mako-1.3.9-py3-none-any.whl", hash = "sha256:95920acccb578427a9aa38e37a186b1e43156c87260d7ba18ca63aa4c7cbd3a1"}, 101 | {file = "mako-1.3.9.tar.gz", hash = "sha256:b5d65ff3462870feec922dbccf38f6efb44e5714d7b593a656be86663d8600ac"}, 102 | ] 103 | 104 | [package.dependencies] 105 | MarkupSafe = ">=0.9.2" 106 | 107 | [package.extras] 108 | babel = ["Babel"] 109 | lingua = ["lingua"] 110 | testing = ["pytest"] 111 | 112 | [[package]] 113 | name = "markupsafe" 114 | version = "3.0.2" 115 | description = "Safely add untrusted strings to HTML/XML markup." 116 | category = "main" 117 | optional = false 118 | python-versions = ">=3.9" 119 | files = [ 120 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, 121 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, 122 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, 123 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, 124 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, 125 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, 126 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, 127 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, 128 | {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, 129 | {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, 130 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, 131 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, 132 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, 133 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, 134 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, 135 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, 136 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, 137 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, 138 | {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, 139 | {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, 140 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, 141 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, 142 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, 143 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, 144 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, 145 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, 146 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, 147 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, 148 | {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, 149 | {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, 150 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, 151 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, 152 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, 153 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, 154 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, 155 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, 156 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, 157 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, 158 | {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, 159 | {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, 160 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, 161 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, 162 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, 163 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, 164 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, 165 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, 166 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, 167 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, 168 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, 169 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, 170 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, 171 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, 172 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, 173 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, 174 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, 175 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, 176 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, 177 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, 178 | {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, 179 | {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, 180 | {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, 181 | ] 182 | 183 | [[package]] 184 | name = "mccabe" 185 | version = "0.7.0" 186 | description = "McCabe checker, plugin for flake8" 187 | category = "dev" 188 | optional = false 189 | python-versions = ">=3.6" 190 | files = [ 191 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 192 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 193 | ] 194 | 195 | [[package]] 196 | name = "platformdirs" 197 | version = "4.3.6" 198 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 199 | category = "dev" 200 | optional = false 201 | python-versions = ">=3.8" 202 | files = [ 203 | {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, 204 | {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, 205 | ] 206 | 207 | [package.extras] 208 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 209 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] 210 | type = ["mypy (>=1.11.2)"] 211 | 212 | [[package]] 213 | name = "pylint" 214 | version = "3.3.4" 215 | description = "python code static checker" 216 | category = "dev" 217 | optional = false 218 | python-versions = ">=3.9.0" 219 | files = [ 220 | {file = "pylint-3.3.4-py3-none-any.whl", hash = "sha256:289e6a1eb27b453b08436478391a48cd53bb0efb824873f949e709350f3de018"}, 221 | {file = "pylint-3.3.4.tar.gz", hash = "sha256:74ae7a38b177e69a9b525d0794bd8183820bfa7eb68cc1bee6e8ed22a42be4ce"}, 222 | ] 223 | 224 | [package.dependencies] 225 | astroid = ">=3.3.8,<=3.4.0-dev0" 226 | colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} 227 | dill = [ 228 | {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, 229 | {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, 230 | ] 231 | isort = ">=4.2.5,<5.13.0 || >5.13.0,<7" 232 | mccabe = ">=0.6,<0.8" 233 | platformdirs = ">=2.2.0" 234 | tomlkit = ">=0.10.1" 235 | 236 | [package.extras] 237 | spelling = ["pyenchant (>=3.2,<4.0)"] 238 | testutils = ["gitpython (>3)"] 239 | 240 | [[package]] 241 | name = "pyyaml" 242 | version = "6.0.2" 243 | description = "YAML parser and emitter for Python" 244 | category = "main" 245 | optional = false 246 | python-versions = ">=3.8" 247 | files = [ 248 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, 249 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, 250 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, 251 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, 252 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, 253 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, 254 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, 255 | {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, 256 | {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, 257 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, 258 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, 259 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, 260 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, 261 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, 262 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, 263 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, 264 | {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, 265 | {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, 266 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, 267 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, 268 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, 269 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, 270 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, 271 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, 272 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, 273 | {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, 274 | {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, 275 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, 276 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, 277 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, 278 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, 279 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, 280 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, 281 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, 282 | {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, 283 | {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, 284 | {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, 285 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, 286 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, 287 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, 288 | {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, 289 | {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, 290 | {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, 291 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, 292 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, 293 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, 294 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, 295 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, 296 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, 297 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, 298 | {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, 299 | {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, 300 | {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, 301 | ] 302 | 303 | [[package]] 304 | name = "tomlkit" 305 | version = "0.13.2" 306 | description = "Style preserving TOML library" 307 | category = "dev" 308 | optional = false 309 | python-versions = ">=3.8" 310 | files = [ 311 | {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, 312 | {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, 313 | ] 314 | 315 | [metadata] 316 | lock-version = "2.0" 317 | python-versions = "^3.11" 318 | content-hash = "97fb82100c89639c475ccfe06b497cbca31a6ee5d8d458e7cf98d1e06e9be3ba" 319 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "incus-incant" 3 | version = "0.1" 4 | description = "" 5 | authors = ["Lucas Nussbaum "] 6 | readme = "README.md" 7 | 8 | [[tool.poetry.packages]] 9 | include = "incant" 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.11" 13 | click = "^8.1.3" 14 | jinja2 = "^3.1.2" 15 | pyyaml = "^6.0" 16 | mako = "^1.1.3" 17 | 18 | [tool.poetry.scripts] 19 | incant = "incant.cli:cli" 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | pylint = "^3.3.4" 23 | 24 | [tool.black] 25 | line-length = 100 26 | 27 | [build-system] 28 | requires = ["poetry-core"] 29 | build-backend = "poetry.core.masonry.api" 30 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnussbaum/incant/e812cb1b8ef4525166da5f7884366fb0a905d297/tests/__init__.py --------------------------------------------------------------------------------