various improvements

This commit is contained in:
ge 2023-11-23 02:34:02 +03:00
parent b9d089dd78
commit 05f90b14f2
200 changed files with 15968 additions and 84 deletions

674
COPYING Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@ -1,36 +1,44 @@
SRC = compute/
DIST = dist/
DOCS_SRC = docs/source/
DOCS_BUILD = docs/build/
SRCDIR = compute
DISTDIR = dist
DOCS_SRCDIR = docs/source
DOCS_BUILDDIR = docs/build
.PHONY: docs
all: build
requirements.txt:
poetry export -f requirements.txt -o requirements.txt
build: format lint
awk '/^version/{print $$3}' pyproject.toml \
| xargs -I {} sed "s/__version__ =.*/__version__ = '{}'/" -i $(SRCDIR)/__init__.py
poetry build
build-deb: build
cd packaging && $(MAKE)
format:
poetry run isort $(SRC)
poetry run ruff format $(SRC)
poetry run isort $(SRCDIR)
poetry run ruff format $(SRCDIR)
lint:
poetry run ruff check $(SRC)
poetry run ruff check $(SRCDIR)
docs:
poetry run sphinx-build $(DOCS_SRC) $(DOCS_BUILD)
poetry run sphinx-build $(DOCS_SRCDIR) $(DOCS_BUILDDIR)
docs-versions:
poetry run sphinx-multiversion $(DOCS_SRC) $(DOCS_BUILD)
poetry run sphinx-multiversion $(DOCS_SRCDIR) $(DOCS_BUILDDIR)
serve-docs:
poetry run sphinx-autobuild $(DOCS_SRC) $(DOCS_BUILD)
poetry run sphinx-autobuild $(DOCS_SRCDIR) $(DOCS_BUILDDIR)
clean:
[ -d $(DIST) ] && rm -rf $(DIST) || true
[ -d $(DOCS_BUILD) ] && rm -rf $(DOCS_BUILD) || true
[ -d $(DISTDIR) ] && rm -rf $(DISTDIR) || true
[ -d $(DOCS_BUILDDIR) ] && rm -rf $(DOCS_BUILDDIR) || true
find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || true
cd packaging && $(MAKE) clean
test-build:
poetry build
scp $(DIST)/*.tar.gz vm:~
test-build: build-deb
scp packaging/build/compute*.deb vm:~

104
README.md
View File

@ -49,3 +49,107 @@ Install [poetry](https://python-poetry.org/), clone this repository and run:
```
poetry install --with dev --with docs
```
# Build Debian package
Install Docker first, then run:
```
make build-deb
```
`compute` and `compute-doc` packages will built. See packaging/build directory.
# Installation
Packages can be installed via `dpkg` or `apt-get`:
```
# apt-get install ./compute*.deb
```
After installation prepare environment, run following command to start libvirtd and create required storage pools:
```
# systemctl enable --now libvirtd.service
# virsh net-start default
# virsh net-autostart default
# for pool in images volumes; do
virsh pool-define-as $pool dir - - - - "/$pool"
virsh pool-build $pool
virsh pool-start $pool
done
```
Then set environment variables in your `~/.profile`, `~/.bashrc` or global in `/etc/profile.d/compute` or `/etc/bash.bashrc`:
```
export CMP_IMAGES_POOL=images
export CMP_VOLUMES_POOL=volumes
```
Configuration file is yet not supported.
Make sure the variables are exported to the environment:
```
printenv | grep CMP_
```
If the command didn't show anything _source_ your rc files or relogin.
# Basic usage
To get help run:
```
compute --help
```
Also you can use `compute` as generic Python library. For example:
```python
from compute import Session
with Session() as session:
instance = session.get_instance('myinstance')
if not instance.is_running():
instance.start()
else:
print('instance is already running')
```
# Create compute instances
Place your qcow2 image in `/volumes` directory. For example `debian_12.qcow2`.
Create `instance.yaml` file with following content:
```yaml
name: myinstance
memory: 2048 # memory in MiB
vcpus: 2
image: debian_12.qcow2
volumes:
- type: file
is_system: true
target: vda
capacity:
value: 10
unit: GiB
```
Refer to `Instance` class docs for more info. Full `instance.yaml` example will be provided later.
To initialise instance run:
```
compute -l debug init instance.yaml
```
Start instance:
```
compute start myinstance
```

View File

@ -1,6 +1,21 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Compute instances management library."""
__version__ = '0.1.0'
__version__ = '0.1.0-dev1'
from .instance import Instance, InstanceConfig, InstanceSchema
from .session import Session

View File

@ -1,6 +1,21 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Command line interface for compute module."""
from compute.cli import control
from compute.cli import main
control.cli()
main.cli()

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Command line interface."""
import argparse
@ -15,10 +30,7 @@ import yaml
from pydantic import ValidationError
from compute import __version__
from compute.exceptions import (
ComputeServiceError,
GuestAgentTimeoutExceededError,
)
from compute.exceptions import ComputeError, GuestAgentTimeoutExceededError
from compute.instance import GuestAgent
from compute.session import Session
from compute.utils import ids
@ -198,10 +210,23 @@ def _create_instance(session: Session, file: io.TextIOWrapper) -> None:
sys.exit()
def _shutdown_instance(session: Session, args: argparse.Namespace) -> None:
instance = session.get_instance(args.instance)
if args.soft:
method = 'SOFT'
elif args.hard:
method = 'HARD'
elif args.unsafe:
method = 'UNSAFE'
else:
method = 'NORMAL'
instance.shutdown(method)
def main(session: Session, args: argparse.Namespace) -> None:
"""Perform actions."""
match args.command:
case 'create':
case 'init':
_create_instance(session, args.file)
case 'exec':
_exec_guest_agent_command(session, args)
@ -211,14 +236,16 @@ def main(session: Session, args: argparse.Namespace) -> None:
instance = session.get_instance(args.instance)
instance.start()
case 'shutdown':
instance = session.get_instance(args.instance)
instance.shutdown(args.method)
_shutdown_instance(session, args)
case 'reboot':
instance = session.get_instance(args.instance)
instance.reboot()
case 'reset':
instance = session.get_instance(args.instance)
instance.reset()
case 'powrst':
instance = session.get_instance(args.instance)
instance.power_reset()
case 'pause':
instance = session.get_instance(args.instance)
instance.pause()
@ -234,7 +261,7 @@ def main(session: Session, args: argparse.Namespace) -> None:
case 'setmem':
instance = session.get_instance(args.instance)
instance.set_memory(args.memory, live=True)
case 'setpasswd':
case 'setpass':
instance = session.get_instance(args.instance)
instance.set_user_password(
args.username,
@ -261,7 +288,6 @@ def cli() -> None: # noqa: PLR0915
'-c',
'--connect',
metavar='URI',
default='qemu:///system',
help='libvirt connection URI',
)
root.add_argument(
@ -270,7 +296,7 @@ def cli() -> None: # noqa: PLR0915
type=str.lower,
metavar='LEVEL',
choices=log_levels,
help='log level [envvar: CMP_LOG]',
help='log level',
)
root.add_argument(
'-V',
@ -280,13 +306,16 @@ def cli() -> None: # noqa: PLR0915
)
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
# create command
create = subparsers.add_parser(
'create', help='create new instance from YAML config file'
# init command
init = subparsers.add_parser(
'init', help='initialise instance using YAML config file'
)
create.add_argument(
init.add_argument(
'file',
type=argparse.FileType('r', encoding='UTF-8'),
nargs='?',
default='instance.yaml',
help='instance config [default: instance.yaml]',
)
# exec subcommand
@ -307,14 +336,14 @@ def cli() -> None: # noqa: PLR0915
default=60,
help=(
'waiting time in seconds for a command to be executed '
'in guest, 60 sec by default'
'in guest [default: 60]'
),
)
execute.add_argument(
'-x',
'--executable',
default='/bin/sh',
help='path to executable in guest, /bin/sh by default',
help='path to executable in guest [default: /bin/sh]',
)
execute.add_argument(
'-e',
@ -352,12 +381,36 @@ def cli() -> None: # noqa: PLR0915
# shutdown subcommand
shutdown = subparsers.add_parser('shutdown', help='shutdown instance')
shutdown.add_argument('instance')
shutdown.add_argument(
'-m',
'--method',
choices=['soft', 'normal', 'hard', 'unsafe'],
default='normal',
help='use shutdown method',
shutdown_opts = shutdown.add_mutually_exclusive_group()
shutdown_opts.add_argument(
'-s',
'--soft',
action='store_true',
help='normal guest OS shutdown, guest agent is used',
)
shutdown_opts.add_argument(
'-n',
'--normal',
action='store_true',
help='shutdown with hypervisor selected method [default]',
)
shutdown_opts.add_argument(
'-H',
'--hard',
action='store_true',
help=(
"gracefully destroy instance, it's like long "
'pressing the power button'
),
)
shutdown_opts.add_argument(
'-u',
'--unsafe',
action='store_true',
help=(
'destroy instance, this is similar to a power outage '
'and may result in data loss or corruption'
),
)
# reboot subcommand
@ -368,6 +421,10 @@ def cli() -> None: # noqa: PLR0915
reset = subparsers.add_parser('reset', help='reset instance')
reset.add_argument('instance')
# powrst subcommand
powrst = subparsers.add_parser('powrst', help='power reset instance')
powrst.add_argument('instance')
# pause subcommand
pause = subparsers.add_parser('pause', help='pause instance')
pause.add_argument('instance')
@ -390,15 +447,15 @@ def cli() -> None: # noqa: PLR0915
setmem.add_argument('instance')
setmem.add_argument('memory', type=int, help='memory in MiB')
# setpasswd subcommand
setpasswd = subparsers.add_parser(
'setpasswd',
# setpass subcommand
setpass = subparsers.add_parser(
'setpass',
help='set user password in guest',
)
setpasswd.add_argument('instance')
setpasswd.add_argument('username')
setpasswd.add_argument('password')
setpasswd.add_argument(
setpass.add_argument('instance')
setpass.add_argument('username')
setpass.add_argument('password')
setpass.add_argument(
'-e',
'--encrypted',
action='store_true',
@ -419,10 +476,18 @@ def cli() -> None: # noqa: PLR0915
)
log.debug('CLI started with args: %s', args)
connect_uri = (
args.connect
or os.getenv('CMP_LIBVIRT_URI')
or os.getenv('LIBVIRT_DEFAULT_URI')
or 'qemu:///system'
)
try:
with Session(args.connect) as session:
with Session(connect_uri) as session:
main(session, args)
except ComputeServiceError as e:
except ComputeError as e:
sys.exit(f'error: {e}')
except KeyboardInterrupt:
sys.exit()

30
compute/common.py Normal file
View File

@ -0,0 +1,30 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Common symbols."""
from abc import ABC, abstractmethod
class EntityConfig(ABC):
"""An abstract entity XML config builder class."""
@abstractmethod
def to_xml(self) -> str:
"""Return device XML config."""
raise NotImplementedError
DeviceConfig = EntityConfig

View File

@ -1,19 +1,34 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Exceptions."""
class ComputeServiceError(Exception):
"""Basic exception class for Compute."""
class ComputeError(Exception):
"""Basic exception class."""
class ConfigLoaderError(ComputeServiceError):
class ConfigLoaderError(ComputeError):
"""Something went wrong when loading configuration."""
class SessionError(ComputeServiceError):
class SessionError(ComputeError):
"""Something went wrong while connecting to libvirtd."""
class GuestAgentError(ComputeServiceError):
class GuestAgentError(ComputeError):
"""Something went wring when QEMU Guest Agent call."""
@ -33,7 +48,7 @@ class GuestAgentCommandNotSupportedError(GuestAgentError):
"""Guest agent command is not supported or blacklisted on guest."""
class StoragePoolError(ComputeServiceError):
class StoragePoolError(ComputeError):
"""Something went wrong when operating with storage pool."""
@ -53,7 +68,7 @@ class VolumeNotFoundError(StoragePoolError):
super().__init__(f"storage volume '{msg}' not found")
class InstanceError(ComputeServiceError):
class InstanceError(ComputeError):
"""Something went wrong while interacting with the domain."""

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from .guest_agent import GuestAgent
from .instance import Instance, InstanceConfig
from .schemas import InstanceSchema

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Interacting with the QEMU Guest Agent."""
import json

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage compute instances."""
__all__ = ['Instance', 'InstanceConfig', 'InstanceInfo']
@ -9,6 +24,7 @@ import libvirt
from lxml import etree
from lxml.builder import E
from compute.common import DeviceConfig, EntityConfig
from compute.exceptions import (
GuestAgentCommandNotSupportedError,
InstanceError,
@ -28,8 +44,8 @@ from .schemas import (
log = logging.getLogger(__name__)
class InstanceConfig:
"""Compute instance config builder."""
class InstanceConfig(EntityConfig):
"""Compute instance XML config builder."""
def __init__(self, schema: InstanceSchema):
"""
@ -181,10 +197,6 @@ class InstanceInfo(NamedTuple):
cputime: int
class DeviceConfig:
"""Abstract device config class."""
class Instance:
"""Manage compute instances."""
@ -492,7 +504,7 @@ class Instance:
return child[0].getparent() if child else None
def attach_device(
self, device: 'DeviceConfig', *, live: bool = False
self, device: DeviceConfig, *, live: bool = False
) -> None:
"""
Attach device to compute instance.
@ -517,7 +529,7 @@ class Instance:
self.domain.attachDeviceFlags(device.to_xml(), flags=flags)
def detach_device(
self, device: 'DeviceConfig', *, live: bool = False
self, device: DeviceConfig, *, live: bool = False
) -> None:
"""
Dettach device from compute instance.
@ -545,8 +557,8 @@ class Instance:
"""
Detach disk device by target name.
There is no ``attach_disk()`` method. Use :method:`attach_device`
with :class:`DiskConfig` as parameter.
There is no ``attach_disk()`` method. Use :func:`attach_device`
with :class:`DiskConfig` as argument.
:param name: Disk name e.g. 'vda', 'sda', etc. This name may
not match the name of the disk inside the guest OS.
@ -574,14 +586,14 @@ class Instance:
raise InstanceError(msg)
self.detach_device(DiskConfig(**disk_params), live=True)
def resize_volume(
def resize_disk(
self, name: str, capacity: int, unit: units.DataUnit
) -> None:
"""
Resize attached block device.
:param name: Disk device name e.g. `vda`, `sda`, etc.
:param capacity: New volume capacity.
:param capacity: New capacity.
:param unit: Capacity unit.
"""
self.domain.blockResize(
@ -590,6 +602,10 @@ class Instance:
flags=libvirt.VIR_DOMAIN_BLOCK_RESIZE_BYTES,
)
def get_disks(self) -> list[DiskConfig]:
"""Return list of attached disks."""
raise NotImplementedError
def pause(self) -> None:
"""Pause instance."""
if not self.is_running():
@ -600,9 +616,9 @@ class Instance:
"""Resume paused instance."""
self.domain.resume()
def list_ssh_keys(self, user: str) -> list[str]:
def get_ssh_keys(self, user: str) -> list[str]:
"""
Get list of SSH keys on guest for specific user.
Return list of SSH keys on guest for specific user.
:param user: Username.
"""
@ -617,7 +633,7 @@ class Instance:
"""
raise NotImplementedError
def remove_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
def delete_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
"""
Remove SSH keys from guest for specific user.
@ -632,7 +648,7 @@ class Instance:
"""
Set new user password in guest OS.
This action performs by guest agent inside guest.
This action performs by guest agent inside the guest.
:param user: Username.
:param password: Password.
@ -653,6 +669,7 @@ class Instance:
return self.domain.XMLDesc(flags)
def delete(self) -> None:
"""Undefine instance and delete local volumes."""
"""Undefine instance."""
# TODO @ge: delete local disks
self.shutdown(method='HARD')
self.domain.undefine()

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Compute instance related objects schemas."""
import re

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Hypervisor session manager."""
import logging

View File

@ -1,2 +1,17 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from .pool import StoragePool
from .volume import DiskConfig, Volume, VolumeConfig

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage storage pools."""
import logging

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage storage volumes."""
from dataclasses import dataclass
@ -8,13 +23,14 @@ import libvirt
from lxml import etree
from lxml.builder import E
from compute.common import DeviceConfig, EntityConfig
from compute.utils import units
@dataclass
class VolumeConfig:
class VolumeConfig(EntityConfig):
"""
Storage volume config builder.
Storage volume XML config builder.
Generate XML config for creating a volume in a libvirt
storage pool.
@ -48,9 +64,9 @@ class VolumeConfig:
@dataclass
class DiskConfig:
class DiskConfig(DeviceConfig):
"""
Disk config builder.
Disk XML config builder.
Generate XML config for attaching or detaching storage volumes
to compute instances.

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Configuration loader."""
import tomllib

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Random identificators."""
# ruff: noqa: S311, C417

View File

@ -1,3 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Tools for data units convertion."""
from enum import StrEnum

View File

@ -1,7 +1,7 @@
# Add ../../compute to path for autodoc
# Add ../.. to path for autodoc Sphinx extension
import os
import sys
sys.path.insert(0, os.path.abspath('../../compute'))
sys.path.insert(0, os.path.abspath('../..'))
# Project information
project = 'Compute'

23
packaging/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM debian:bookworm-slim
WORKDIR /mnt/build
RUN apt-get update; \
env DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
build-essential \
bash-completion \
debhelper \
quilt \
dh-make \
dh-python \
pybuild-plugin-pyproject \
python3-poetry-core \
python3-all \
python3-setuptools \
python3-sphinx \
python3-sphinx-multiversion \
python3-libvirt \
python3-lxml \
python3-yaml \
python3-pydantic; \
apt clean; \
echo "alias ll='ls -alFh'" >> /etc/bash.bashrc
USER 1000:1000

24
packaging/Makefile Normal file
View File

@ -0,0 +1,24 @@
DOCKER_CMD ?= docker
DOCKER_IMG = pybuilder:bookworm
DEBBUILDDIR = build
all: docker-build build
clean:
test -d $(DEBBUILDDIR) && rm -rf $(DEBBUILDDIR) || true
docker-build:
$(DOCKER_CMD) build -f Dockerfile -t $(DOCKER_IMG) .
build: clean
mkdir -p $(DEBBUILDDIR)
cp -v ../dist/compute-*[.tar.gz] $(DEBBUILDDIR)/
cp -r ../docs $(DEBBUILDDIR)/
if [ -f build.sh.bak ]; then mv build.sh{.bak,}; fi
cp build.sh{,.bak}
awk '/authors/{gsub(/[\[\]]/,"");print $$3" "$$4}' ../pyproject.toml \
| sed "s/['<>]//g" \
| tr ' ' '\n' \
| xargs -I {} sed "0,/%placeholder%/s//{}/" -i build.sh
$(DOCKER_CMD) run --rm -i -v $$PWD:/mnt $(DOCKER_IMG) bash < build.sh
mv build.sh{.bak,}

15
packaging/build.sh Normal file
View File

@ -0,0 +1,15 @@
set -o errexit
set -o xtrace
export USER=build
export HOME=/mnt
export DEBFULLNAME='%placeholder%'
export DEBEMAIL='%placeholder%'
mkdir -p /mnt/build && cd /mnt/build
tar xf compute-*[.tar.gz]
cd compute-*[^.tar.gz]
sed -e "s%\.\./\.\.%$PWD%" -i ../docs/source/conf.py
dh_make --copyright gpl3 --yes --python --file ../compute-*[.tar.gz]
rm debian/*.ex debian/README.{Debian,source} debian/*.docs
sed -e 's/\* Initial release.*/\* This is the development build, see commits in upstream repo for info./' -i debian/changelog
cp -v ../../files/{control,rules,copyright,docs,compute.bash-completion} debian/
dpkg-buildpackage -us -uc

Binary file not shown.

View File

@ -0,0 +1,81 @@
Metadata-Version: 2.1
Name: compute
Version: 0.1.0.dev1
Summary: Compute instances management library and tools
Author: ge
Author-email: ge@nixhacks.net
Requires-Python: >=3.11,<4.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Requires-Dist: libvirt-python (==9.0.0)
Requires-Dist: lxml (>=4.9.2,<5.0.0)
Requires-Dist: pydantic (==1.10.4)
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
Description-Content-Type: text/markdown
# Compute
Compute instances management library and tools.
## Docs
Run `make serve-docs`. See [Development](#development) below.
## Roadmap
- [x] Create instances
- [ ] CDROM
- [ ] cloud-init for provisioning instances
- [x] Instance power management
- [x] Instance pause and resume
- [x] vCPU hotplug
- [x] Memory hotplug
- [x] Hot disk resize [not tested]
- [ ] CPU topology customization
- [x] CPU customization (emulation mode, model, vendor, features)
- [ ] BIOS/UEFI settings
- [x] Device attaching
- [x] Device detaching
- [ ] GPU passthrough
- [ ] CPU guarantied resource percent support
- [x] QEMU Guest Agent management
- [ ] Instance resources usage stats
- [ ] SSH-keys management
- [x] Setting user passwords in guest
- [x] QCOW2 disks support
- [ ] ZVOL support
- [ ] Network disks support
- [ ] Images service integration (Images service is not implemented yet)
- [ ] Manage storage pools
- [ ] Idempotency
- [ ] CLI [in progress]
- [ ] HTTP API
- [ ] Instance migrations
- [ ] Instance snapshots
- [ ] Instance backups
- [ ] LXC
## Development
Python 3.11+ is required.
Install [poetry](https://python-poetry.org/), clone this repository and run:
```
poetry install --with dev --with docs
```
# Build Debian package
Install Docker first, then run:
```
make build-deb
```
`compute` and `compute-doc` packages will built. See packaging/build directory. Packages can be installed via `dpkg` or `apt-get`:
```
apt-get install ./compute*.deb
```

View File

@ -0,0 +1,23 @@
../scripts/compute,sha256=b-Gj6H6ssfbGalpouUMSX5pmsjqDnN9xMdTwnU-UfZY,216
compute/__init__.py,sha256=x4zp_CoVPKgDT6AqhometspAyinGxJUXO48duJ5aHUM,873
compute/__main__.py,sha256=zJyKJul6pCbguFPtVLZBoAuZl9RXibn4CCMn46jIgUQ,745
compute/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
compute/cli/control.py,sha256=83wnR21pHOPyyk1i1n_YBIDz6dCFB6hmuIFguIk68rs,14634
compute/common.py,sha256=G1qwC1EybG5LEJtyoux9ymiqB2ZOsgKXlCpbuhHv55Y,948
compute/exceptions.py,sha256=Ga59L55qSAPeyDfjANPuMh4yVSRWHDYi9xqq5o4_7-0,2452
compute/instance/__init__.py,sha256=kHN8jVamyrBZYZgi62tPtJ7rS73gUPhfswLalmPA5Zs,772
compute/instance/guest_agent.py,sha256=fq89kQbcV5X5eFCsMmujRuwTOSghWO4ZhAjvxyUu84M,7018
compute/instance/instance.py,sha256=WP6oTJfdAf6QlefwVLqdC8J6XoKHum6nZhwwHOEtjNk,23297
compute/instance/schemas.py,sha256=B51ytPlxhnx0MrkR2WYhd49RaRT7Is7NsIM9OrMUpvI,4288
compute/session.py,sha256=znYOIzoiCbSG62k-ViaXti_lOnw88wD8Syp3nCXAJ28,10050
compute/storage/__init__.py,sha256=zNaVjZ2925DxrVUFWwVRsGU6bSYbF46sb4L6NsaiKbw,736
compute/storage/pool.py,sha256=9z99bBDbb4ATGpfMkEWpxAO4fEQHNVOxxf0iUln9cN0,4197
compute/storage/volume.py,sha256=_TbK9Y4d3NAeknPUiuhldAT3ZaN1sZgjy4QzC-Sw4Io,4110
compute/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
compute/utils/config_loader.py,sha256=ul1J3sZg0D9R0HbOz5Pg9JmL4nFaMahAzQEdGaWFABU,1989
compute/utils/ids.py,sha256=fg6Xsg4OMM-BIaU3DPu0L91ICwx-L3qNoELEwQZz2s0,1007
compute/utils/units.py,sha256=UkwD0zQ-rlpSpkbfezCcvJx4D8iZlI9M-oXXvdVEvy0,1549
compute-0.1.0.dev1.dist-info/METADATA,sha256=tbX8xp92Jwqf44sOwPB-HqKHLezab5dU9DrQDYFitDQ,1944
compute-0.1.0.dev1.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
compute-0.1.0.dev1.dist-info/entry_points.txt,sha256=xHhg-Fo9Z5gJnIahbG8pVIGNDqlH5Eordn8hnXUwscw,51
compute-0.1.0.dev1.dist-info/RECORD,,

View File

@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: poetry-core 1.4.0
Root-Is-Purelib: true
Tag: py3-none-any

View File

@ -0,0 +1,3 @@
[console_scripts]
compute=compute.cli.control:cli

View File

@ -0,0 +1,22 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Compute instances management library."""
__version__ = '0.1.0-dev1'
from .instance import Instance, InstanceConfig, InstanceSchema
from .session import Session
from .storage import StoragePool, Volume, VolumeConfig

View File

@ -0,0 +1,21 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Command line interface for compute module."""
from compute.cli import main
main.cli()

View File

@ -0,0 +1,501 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Command line interface."""
import argparse
import io
import logging
import os
import shlex
import sys
from collections import UserDict
from typing import Any
from uuid import uuid4
import libvirt
import yaml
from pydantic import ValidationError
from compute import __version__
from compute.exceptions import ComputeError, GuestAgentTimeoutExceededError
from compute.instance import GuestAgent
from compute.session import Session
from compute.utils import ids
log = logging.getLogger(__name__)
log_levels = [lv.lower() for lv in logging.getLevelNamesMapping()]
libvirt.registerErrorHandler(
lambda userdata, err: None, # noqa: ARG005
ctx=None,
)
class Table:
"""Minimalistic text table constructor."""
def __init__(self, whitespace: str | None = None):
"""Initialise Table."""
self.whitespace = whitespace or '\t'
self.header = []
self.rows = []
self.table = ''
def add_row(self, row: list) -> None:
"""Add table row."""
self.rows.append([str(col) for col in row])
def add_rows(self, rows: list[list]) -> None:
"""Add multiple rows."""
for row in rows:
self.add_row(row)
def __str__(self) -> str:
"""Build table and return."""
widths = [max(map(len, col)) for col in zip(*self.rows, strict=True)]
self.rows.insert(0, [str(h).upper() for h in self.header])
for row in self.rows:
self.table += self.whitespace.join(
(
val.ljust(width)
for val, width in zip(row, widths, strict=True)
)
)
self.table += '\n'
return self.table.strip()
def _list_instances(session: Session) -> None:
table = Table()
table.header = ['NAME', 'STATE']
for instance in session.list_instances():
table.add_row(
[
instance.name,
instance.get_status(),
]
)
print(table)
sys.exit()
def _exec_guest_agent_command(
session: Session, args: argparse.Namespace
) -> None:
instance = session.get_instance(args.instance)
ga = GuestAgent(instance.domain, timeout=args.timeout)
arguments = args.arguments.copy()
if len(arguments) > 1 and not args.no_join_args:
arguments = [shlex.join(arguments)]
if not args.no_join_args:
arguments.insert(0, '-c')
stdin = None
if not sys.stdin.isatty():
stdin = sys.stdin.read()
try:
output = ga.guest_exec(
path=args.executable,
args=arguments,
env=args.env,
stdin=stdin,
capture_output=True,
decode_output=True,
poll=True,
)
except GuestAgentTimeoutExceededError as e:
sys.exit(
f'{e}. NOTE: command may still running in guest, '
f'PID={ga.last_pid}'
)
if output.stderr:
print(output.stderr.strip(), file=sys.stderr)
if output.stdout:
print(output.stdout.strip(), file=sys.stdout)
sys.exit(output.exitcode)
class _NotPresent:
"""
Type for representing non-existent dictionary keys.
See :class:`_FillableDict`.
"""
class _FillableDict(UserDict):
"""Use :method:`fill` to add key if not present."""
def __init__(self, data: dict):
self.data = data
def fill(self, key: str, value: Any) -> None: # noqa: ANN401
if self.data.get(key, _NotPresent) is _NotPresent:
self.data[key] = value
def _merge_dicts(a: dict, b: dict, path: list[str] | None = None) -> dict:
"""Merge `b` into `a`. Return modified `a`."""
if path is None:
path = []
for key in b:
if key in a:
if isinstance(a[key], dict) and isinstance(b[key], dict):
_merge_dicts(a[key], b[key], [path + str(key)])
elif a[key] == b[key]:
pass # same leaf value
else:
a[key] = b[key] # replace existing key's values
else:
a[key] = b[key]
return a
def _create_instance(session: Session, file: io.TextIOWrapper) -> None:
try:
data = _FillableDict(yaml.load(file.read(), Loader=yaml.SafeLoader))
log.debug('Read from file: %s', data)
except yaml.YAMLError as e:
sys.exit(f'error: cannot parse YAML: {e}')
capabilities = session.get_capabilities()
node_info = session.get_node_info()
data.fill('name', uuid4().hex)
data.fill('title', None)
data.fill('description', None)
data.fill('arch', capabilities.arch)
data.fill('machine', capabilities.machine)
data.fill('emulator', capabilities.emulator)
data.fill('max_vcpus', node_info.cpus)
data.fill('max_memory', node_info.memory)
data.fill('cpu', {})
cpu = {
'emulation_mode': 'host-passthrough',
'model': None,
'vendor': None,
'topology': None,
'features': None,
}
data['cpu'] = _merge_dicts(data['cpu'], cpu)
data.fill(
'network_interfaces',
[{'source': 'default', 'mac': ids.random_mac()}],
)
data.fill('boot', {'order': ['cdrom', 'hd']})
try:
log.debug('Input data: %s', data)
session.create_instance(**data)
except ValidationError as e:
for error in e.errors():
fields = '.'.join([str(lc) for lc in error['loc']])
print(
f"validation error: {fields}: {error['msg']}",
file=sys.stderr,
)
sys.exit()
def _shutdown_instance(session: Session, args: argparse.Namespace) -> None:
instance = session.get_instance(args.instance)
if args.soft:
method = 'SOFT'
elif args.hard:
method = 'HARD'
elif args.unsafe:
method = 'UNSAFE'
else:
method = 'NORMAL'
instance.shutdown(method)
def main(session: Session, args: argparse.Namespace) -> None:
"""Perform actions."""
match args.command:
case 'init':
_create_instance(session, args.file)
case 'exec':
_exec_guest_agent_command(session, args)
case 'ls':
_list_instances(session)
case 'start':
instance = session.get_instance(args.instance)
instance.start()
case 'shutdown':
_shutdown_instance(session, args)
case 'reboot':
instance = session.get_instance(args.instance)
instance.reboot()
case 'reset':
instance = session.get_instance(args.instance)
instance.reset()
case 'powrst':
instance = session.get_instance(args.instance)
instance.power_reset()
case 'pause':
instance = session.get_instance(args.instance)
instance.pause()
case 'resume':
instance = session.get_instance(args.instance)
instance.resume()
case 'status':
instance = session.get_instance(args.instance)
print(instance.status)
case 'setvcpus':
instance = session.get_instance(args.instance)
instance.set_vcpus(args.nvcpus, live=True)
case 'setmem':
instance = session.get_instance(args.instance)
instance.set_memory(args.memory, live=True)
case 'setpass':
instance = session.get_instance(args.instance)
instance.set_user_password(
args.username,
args.password,
encrypted=args.encrypted,
)
def cli() -> None: # noqa: PLR0915
"""Return command line arguments parser."""
root = argparse.ArgumentParser(
prog='compute',
description='manage compute instances',
formatter_class=argparse.RawTextHelpFormatter,
)
root.add_argument(
'-v',
'--verbose',
action='store_true',
default=False,
help='enable verbose mode',
)
root.add_argument(
'-c',
'--connect',
metavar='URI',
help='libvirt connection URI',
)
root.add_argument(
'-l',
'--log-level',
type=str.lower,
metavar='LEVEL',
choices=log_levels,
help='log level',
)
root.add_argument(
'-V',
'--version',
action='version',
version=__version__,
)
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
# init command
init = subparsers.add_parser(
'init', help='initialise instance using YAML config file'
)
init.add_argument(
'file',
type=argparse.FileType('r', encoding='UTF-8'),
nargs='?',
default='instance.yaml',
help='instance config [default: instance.yaml]',
)
# exec subcommand
execute = subparsers.add_parser(
'exec',
help='execute command in guest via guest agent',
description=(
'NOTE: any argument after instance name will be passed into '
'guest as shell command.'
),
)
execute.add_argument('instance')
execute.add_argument('arguments', nargs=argparse.REMAINDER)
execute.add_argument(
'-t',
'--timeout',
type=int,
default=60,
help=(
'waiting time in seconds for a command to be executed '
'in guest [default: 60]'
),
)
execute.add_argument(
'-x',
'--executable',
default='/bin/sh',
help='path to executable in guest [default: /bin/sh]',
)
execute.add_argument(
'-e',
'--env',
type=str,
nargs='?',
action='append',
help='environment variables to pass to executable in guest',
)
execute.add_argument(
'-n',
'--no-join-args',
action='store_true',
default=False,
help=(
"do not join arguments list and add '-c' option, suitable "
'for non-shell executables and other specific cases.'
),
)
# ls subcommand
listall = subparsers.add_parser('ls', help='list instances')
listall.add_argument(
'-a',
'--all',
action='store_true',
default=False,
help='list all instances including inactive',
)
# start subcommand
start = subparsers.add_parser('start', help='start instance')
start.add_argument('instance')
# shutdown subcommand
shutdown = subparsers.add_parser('shutdown', help='shutdown instance')
shutdown.add_argument('instance')
shutdown_opts = shutdown.add_mutually_exclusive_group()
shutdown_opts.add_argument(
'-s',
'--soft',
action='store_true',
help='normal guest OS shutdown, guest agent is used',
)
shutdown_opts.add_argument(
'-n',
'--normal',
action='store_true',
help='shutdown with hypervisor selected method [default]',
)
shutdown_opts.add_argument(
'-H',
'--hard',
action='store_true',
help=(
"gracefully destroy instance, it's like long "
'pressing the power button'
),
)
shutdown_opts.add_argument(
'-u',
'--unsafe',
action='store_true',
help=(
'destroy instance, this is similar to a power outage '
'and may result in data loss or corruption'
),
)
# reboot subcommand
reboot = subparsers.add_parser('reboot', help='reboot instance')
reboot.add_argument('instance')
# reset subcommand
reset = subparsers.add_parser('reset', help='reset instance')
reset.add_argument('instance')
# powrst subcommand
powrst = subparsers.add_parser('powrst', help='power reset instance')
powrst.add_argument('instance')
# pause subcommand
pause = subparsers.add_parser('pause', help='pause instance')
pause.add_argument('instance')
# resume subcommand
resume = subparsers.add_parser('resume', help='resume paused instance')
resume.add_argument('instance')
# status subcommand
status = subparsers.add_parser('status', help='display instance status')
status.add_argument('instance')
# setvcpus subcommand
setvcpus = subparsers.add_parser('setvcpus', help='set vCPU number')
setvcpus.add_argument('instance')
setvcpus.add_argument('nvcpus', type=int)
# setmem subcommand
setmem = subparsers.add_parser('setmem', help='set memory size')
setmem.add_argument('instance')
setmem.add_argument('memory', type=int, help='memory in MiB')
# setpass subcommand
setpass = subparsers.add_parser(
'setpass',
help='set user password in guest',
)
setpass.add_argument('instance')
setpass.add_argument('username')
setpass.add_argument('password')
setpass.add_argument(
'-e',
'--encrypted',
action='store_true',
default=False,
help='set it if password is already encrypted',
)
args = root.parse_args()
if args.command is None:
root.print_help()
sys.exit()
log_level = args.log_level or os.getenv('CMP_LOG')
if isinstance(log_level, str) and log_level.lower() in log_levels:
logging.basicConfig(
level=logging.getLevelNamesMapping()[log_level.upper()]
)
log.debug('CLI started with args: %s', args)
connect_uri = (
args.connect
or os.getenv('CMP_LIBVIRT_URI')
or os.getenv('LIBVIRT_DEFAULT_URI')
or 'qemu:///system'
)
try:
with Session(connect_uri) as session:
main(session, args)
except ComputeError as e:
sys.exit(f'error: {e}')
except KeyboardInterrupt:
sys.exit()
except SystemExit as e:
sys.exit(e)
except Exception as e: # noqa: BLE001
sys.exit(f'unexpected error {type(e)}: {e}')
if __name__ == '__main__':
cli()

View File

@ -0,0 +1,30 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Common symbols."""
from abc import ABC, abstractmethod
class EntityConfig(ABC):
"""An abstract entity XML config builder class."""
@abstractmethod
def to_xml(self) -> str:
"""Return device XML config."""
raise NotImplementedError
DeviceConfig = EntityConfig

View File

@ -0,0 +1,80 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Exceptions."""
class ComputeError(Exception):
"""Basic exception class."""
class ConfigLoaderError(ComputeError):
"""Something went wrong when loading configuration."""
class SessionError(ComputeError):
"""Something went wrong while connecting to libvirtd."""
class GuestAgentError(ComputeError):
"""Something went wring when QEMU Guest Agent call."""
class GuestAgentUnavailableError(GuestAgentError):
"""Guest agent is not connected or is unavailable."""
class GuestAgentTimeoutExceededError(GuestAgentError):
"""QEMU timeout exceeded."""
def __init__(self, msg: int):
"""Initialise GuestAgentTimeoutExceededError."""
super().__init__(f'QEMU timeout ({msg} sec) exceeded')
class GuestAgentCommandNotSupportedError(GuestAgentError):
"""Guest agent command is not supported or blacklisted on guest."""
class StoragePoolError(ComputeError):
"""Something went wrong when operating with storage pool."""
class StoragePoolNotFoundError(StoragePoolError):
"""Storage pool not found."""
def __init__(self, msg: str):
"""Initialise StoragePoolNotFoundError."""
super().__init__(f"storage pool named '{msg}' not found")
class VolumeNotFoundError(StoragePoolError):
"""Storage volume not found."""
def __init__(self, msg: str):
"""Initialise VolumeNotFoundError."""
super().__init__(f"storage volume '{msg}' not found")
class InstanceError(ComputeError):
"""Something went wrong while interacting with the domain."""
class InstanceNotFoundError(InstanceError):
"""Virtual machine or container not found on compute node."""
def __init__(self, msg: str):
"""Initialise InstanceNotFoundError."""
super().__init__(f"compute instance '{msg}' not found")

View File

@ -0,0 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from .guest_agent import GuestAgent
from .instance import Instance, InstanceConfig
from .schemas import InstanceSchema

View File

@ -0,0 +1,208 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Interacting with the QEMU Guest Agent."""
import json
import logging
from base64 import b64decode, standard_b64encode
from time import sleep, time
from typing import NamedTuple
import libvirt
import libvirt_qemu
from compute.exceptions import (
GuestAgentCommandNotSupportedError,
GuestAgentError,
GuestAgentTimeoutExceededError,
GuestAgentUnavailableError,
)
log = logging.getLogger(__name__)
class GuestExecOutput(NamedTuple):
"""QEMU guest-exec command output."""
exited: bool | None = None
exitcode: int | None = None
stdout: str | None = None
stderr: str | None = None
class GuestAgent:
"""Class for interacting with QEMU guest agent."""
def __init__(self, domain: libvirt.virDomain, timeout: int = 60):
"""
Initialise GuestAgent.
:param domain: Libvirt domain object
:param timeout: QEMU timeout
"""
self.domain = domain
self.timeout = timeout
self.flags = libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
self.last_pid = None
def execute(self, command: dict) -> dict:
"""
Execute QEMU guest agent command.
See: https://qemu-project.gitlab.io/qemu/interop/qemu-ga-ref.html
:param command: QEMU guest agent command as dict
:return: Command output
:rtype: dict
"""
log.debug(command)
try:
output = libvirt_qemu.qemuAgentCommand(
self.domain, json.dumps(command), self.timeout, self.flags
)
return json.loads(output)
except libvirt.libvirtError as e:
if e.get_error_code() == libvirt.VIR_ERR_AGENT_UNRESPONSIVE:
raise GuestAgentUnavailableError(e) from e
raise GuestAgentError(e) from e
def is_available(self) -> bool:
"""
Execute guest-ping.
:return: True or False if guest agent is unreachable.
:rtype: bool
"""
try:
if self.execute({'execute': 'guest-ping', 'arguments': {}}):
return True
except GuestAgentError:
return False
def get_supported_commands(self) -> set[str]:
"""Return set of supported guest agent commands."""
output = self.execute({'execute': 'guest-info', 'arguments': {}})
return {
cmd['name']
for cmd in output['return']['supported_commands']
if cmd['enabled'] is True
}
def raise_for_commands(self, commands: list[str]) -> None:
"""
Raise exception if QEMU GA command is not available.
:param commands: List of required commands
:raise: GuestAgentCommandNotSupportedError
"""
supported = self.get_supported_commands()
for command in commands:
if command not in supported:
raise GuestAgentCommandNotSupportedError(command)
def guest_exec( # noqa: PLR0913
self,
path: str,
args: list[str] | None = None,
env: list[str] | None = None,
stdin: str | None = None,
*,
capture_output: bool = False,
decode_output: bool = False,
poll: bool = False,
) -> GuestExecOutput:
"""
Execute qemu-exec command and return output.
:param path: Path ot executable on guest.
:param arg: List of arguments to pass to executable.
:param env: List of environment variables to pass to executable.
For example: ``['LANG=C', 'TERM=xterm']``
:param stdin: Data to pass to executable STDIN.
:param capture_output: Capture command output.
:param decode_output: Use base64_decode() to decode command output.
Affects only if `capture_output` is True.
:param poll: Poll command output. Uses `self.timeout` and
POLL_INTERVAL constant.
:return: Command output
:rtype: GuestExecOutput
"""
self.raise_for_commands(['guest-exec', 'guest-exec-status'])
command = {
'execute': 'guest-exec',
'arguments': {
'path': path,
**({'arg': args} if args else {}),
**({'env': env} if env else {}),
**(
{
'input-data': standard_b64encode(
stdin.encode('utf-8')
).decode('utf-8')
}
if stdin
else {}
),
'capture-output': capture_output,
},
}
output = self.execute(command)
self.last_pid = pid = output['return']['pid']
command_status = self.guest_exec_status(pid, poll=poll)['return']
exited = command_status['exited']
exitcode = command_status['exitcode']
stdout = command_status.get('out-data', None)
stderr = command_status.get('err-data', None)
if decode_output:
stdout = b64decode(stdout or '').decode('utf-8')
stderr = b64decode(stderr or '').decode('utf-8')
return GuestExecOutput(exited, exitcode, stdout, stderr)
def guest_exec_status(
self, pid: int, *, poll: bool = False, poll_interval: float = 0.3
) -> dict:
"""
Execute guest-exec-status and return output.
:param pid: PID in guest.
:param poll: If True poll command status.
:param poll_interval: Time between attempts to obtain command status.
:return: Command output
:rtype: dict
"""
self.raise_for_commands(['guest-exec-status'])
command = {
'execute': 'guest-exec-status',
'arguments': {'pid': pid},
}
if not poll:
return self.execute(command)
start_time = time()
while True:
command_status = self.execute(command)
if command_status['return']['exited']:
break
sleep(poll_interval)
now = time()
if now - start_time > self.timeout:
raise GuestAgentTimeoutExceededError(self.timeout)
log.debug(
'Polling command pid=%s finished, time taken: %s seconds',
pid,
int(time() - start_time),
)
return command_status

View File

@ -0,0 +1,675 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage compute instances."""
__all__ = ['Instance', 'InstanceConfig', 'InstanceInfo']
import logging
from typing import NamedTuple
import libvirt
from lxml import etree
from lxml.builder import E
from compute.common import DeviceConfig, EntityConfig
from compute.exceptions import (
GuestAgentCommandNotSupportedError,
InstanceError,
)
from compute.storage import DiskConfig
from compute.utils import units
from .guest_agent import GuestAgent
from .schemas import (
CPUEmulationMode,
CPUSchema,
InstanceSchema,
NetworkInterfaceSchema,
)
log = logging.getLogger(__name__)
class InstanceConfig(EntityConfig):
"""Compute instance XML config builder."""
def __init__(self, schema: InstanceSchema):
"""
Initialise InstanceConfig.
:param schema: InstanceSchema object
"""
self.name = schema.name
self.title = schema.title
self.description = schema.description
self.memory = schema.memory
self.max_memory = schema.max_memory
self.vcpus = schema.vcpus
self.max_vcpus = schema.max_vcpus
self.cpu = schema.cpu
self.machine = schema.machine
self.emulator = schema.emulator
self.arch = schema.arch
self.boot = schema.boot
self.network_interfaces = schema.network_interfaces
def _gen_cpu_xml(self, cpu: CPUSchema) -> etree.Element:
options = {
'mode': cpu.emulation_mode,
'match': 'exact',
'check': 'partial',
}
if cpu.emulation_mode == CPUEmulationMode.HOST_PASSTHROUGH:
options['check'] = 'none'
options['migratable'] = 'on'
xml = E.cpu(**options)
if cpu.model:
xml.append(E.model(cpu.model, fallback='forbid'))
if cpu.vendor:
xml.append(E.vendor(cpu.vendor))
if cpu.topology:
xml.append(
E.topology(
sockets=str(cpu.topology.sockets),
dies=str(cpu.topology.dies),
cores=str(cpu.topology.cores),
threads=str(cpu.topology.threads),
)
)
if cpu.features:
for feature in cpu.features.require:
xml.append(E.feature(policy='require', name=feature))
for feature in cpu.features.disable:
xml.append(E.feature(policy='disable', name=feature))
return xml
def _gen_vcpus_xml(self, vcpus: int, max_vcpus: int) -> etree.Element:
xml = E.vcpus()
xml.append(E.vcpu(id='0', enabled='yes', hotpluggable='no', order='1'))
for i in range(max_vcpus - 1):
enabled = 'yes' if (i + 2) <= vcpus else 'no'
xml.append(
E.vcpu(
id=str(i + 1),
enabled=enabled,
hotpluggable='yes',
order=str(i + 2),
)
)
return xml
def _gen_network_interface_xml(
self, interface: NetworkInterfaceSchema
) -> etree.Element:
return E.interface(
E.source(network=interface.source),
E.mac(address=interface.mac),
type='network',
)
def to_xml(self) -> str:
"""Return XML config for libvirt."""
xml = E.domain(type='kvm')
xml.append(E.name(self.name))
if self.title:
xml.append(E.title(self.title))
if self.description:
xml.append(E.description(self.description))
xml.append(E.metadata())
xml.append(E.memory(str(self.max_memory * 1024), unit='KiB'))
xml.append(E.currentMemory(str(self.memory * 1024), unit='KiB'))
xml.append(
E.vcpu(
str(self.max_vcpus),
placement='static',
current=str(self.vcpus),
)
)
xml.append(self._gen_cpu_xml(self.cpu))
os = E.os(E.type('hvm', machine=self.machine, arch=self.arch))
for dev in self.boot.order:
os.append(E.boot(dev=dev))
xml.append(os)
xml.append(E.features(E.acpi(), E.apic()))
xml.append(E.on_poweroff('destroy'))
xml.append(E.on_reboot('restart'))
xml.append(E.on_crash('restart'))
xml.append(
E.pm(
E('suspend-to-mem', enabled='no'),
E('suspend-to-disk', enabled='no'),
)
)
devices = E.devices()
devices.append(E.emulator(str(self.emulator)))
for interface in self.network_interfaces:
devices.append(self._gen_network_interface_xml(interface))
devices.append(E.graphics(type='vnc', port='-1', autoport='yes'))
devices.append(E.input(type='tablet', bus='usb'))
devices.append(
E.channel(
E.source(mode='bind'),
E.target(type='virtio', name='org.qemu.guest_agent.0'),
E.address(
type='virtio-serial', controller='0', bus='0', port='1'
),
type='unix',
)
)
devices.append(
E.console(E.target(type='serial', port='0'), type='pty')
)
devices.append(
E.video(
E.model(type='vga', vram='16384', heads='1', primary='yes')
)
)
xml.append(devices)
return etree.tostring(xml, encoding='unicode', pretty_print=True)
class InstanceInfo(NamedTuple):
"""
Store compute instance info.
Reference:
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainInfo
"""
state: str
max_memory: int
memory: int
nproc: int
cputime: int
class Instance:
"""Manage compute instances."""
def __init__(self, domain: libvirt.virDomain):
"""
Initialise Instance.
:ivar libvirt.virDomain domain: domain object
:ivar libvirt.virConnect connection: connection object
:ivar str name: domain name
:ivar GuestAgent guest_agent: :class:`GuestAgent` object
:param domain: libvirt domain object
"""
self.domain = domain
self.connection = domain.connect()
self.name = domain.name()
self.guest_agent = GuestAgent(domain)
def _expand_instance_state(self, state: int) -> str:
states = {
libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
libvirt.VIR_DOMAIN_RUNNING: 'running',
libvirt.VIR_DOMAIN_BLOCKED: 'blocked',
libvirt.VIR_DOMAIN_PAUSED: 'paused',
libvirt.VIR_DOMAIN_SHUTDOWN: 'shutdown',
libvirt.VIR_DOMAIN_SHUTOFF: 'shutoff',
libvirt.VIR_DOMAIN_CRASHED: 'crashed',
libvirt.VIR_DOMAIN_PMSUSPENDED: 'pmsuspended',
}
return states[state]
def get_info(self) -> InstanceInfo:
"""Return instance info."""
info = self.domain.info()
return InstanceInfo(
state=self._expand_instance_state(info[0]),
max_memory=info[1],
memory=info[2],
nproc=info[3],
cputime=info[4],
)
def get_status(self) -> str:
"""
Return instance state: 'running', 'shutoff', etc.
Reference:
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
"""
try:
state, _ = self.domain.state()
except libvirt.libvirtError as e:
raise InstanceError(
'Cannot fetch status of ' f'instance={self.name}: {e}'
) from e
return self._expand_instance_state(state)
def is_running(self) -> bool:
"""Return True if instance is running, else return False."""
if self.domain.isActive() != 1:
# 0 - is inactive, -1 - is error
return False
return True
def is_autostart(self) -> bool:
"""Return True if instance autostart is enabled, else return False."""
try:
return bool(self.domain.autostart())
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot get autostart status for '
f'instance={self.name}: {e}'
) from e
def get_max_memory(self) -> int:
"""Maximum memory value for domain in KiB."""
return self.domain.maxMemory()
def get_max_vcpus(self) -> int:
"""Maximum vCPUs number for domain."""
return self.domain.maxVcpus()
def start(self) -> None:
"""Start defined instance."""
log.info('Starting instnce=%s', self.name)
if self.is_running():
log.warning(
'Already started, nothing to do instance=%s', self.name
)
return
try:
self.domain.create()
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot start instance={self.name}: {e}'
) from e
def shutdown(self, method: str | None = None) -> None:
"""
Shutdown instance.
Shutdown methods:
SOFT
Use guest agent to shutdown. If guest agent is unavailable
NORMAL method will be used.
NORMAL
Use method choosen by hypervisor to shutdown. Usually send ACPI
signal to guest OS. OS may ignore ACPI e.g. if guest is hanged.
HARD
Shutdown instance without any guest OS shutdown. This is simular
to unplugging machine from power. Internally send SIGTERM to
instance process and destroy it gracefully.
UNSAFE
Force shutdown. Internally send SIGKILL to instance process.
There is high data corruption risk!
If method is None NORMAL method will used.
:param method: Method used to shutdown instance
"""
methods = {
'SOFT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
'HARD': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL,
'UNSAFE': libvirt.VIR_DOMAIN_DESTROY_DEFAULT,
}
if method is None:
method = 'NORMAL'
if not isinstance(method, str):
raise TypeError(
f"Shutdown method must be a 'str', not {type(method)}"
)
method = method.upper()
if method not in methods:
raise ValueError(f"Unsupported shutdown method: '{method}'")
try:
if method in ['SOFT', 'NORMAL']:
self.domain.shutdownFlags(flags=methods[method])
elif method in ['HARD', 'UNSAFE']:
self.domain.destroyFlags(flags=methods[method])
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot shutdown instance={self.name} ' f'{method=}: {e}'
) from e
def reboot(self) -> None:
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
try:
self.domain.reboot()
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot reboot instance={self.name}: {e}'
) from e
def reset(self) -> None:
"""
Reset instance.
Copypaste from libvirt doc:
Reset a domain immediately without any guest OS shutdown.
Reset emulates the power reset button on a machine, where all
hardware sees the RST line set and reinitializes internal state.
Note that there is a risk of data loss caused by reset without any
guest OS shutdown.
"""
try:
self.domain.reset()
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot reset instance={self.name}: {e}'
) from e
def power_reset(self) -> None:
"""
Shutdown instance and start.
By analogy with real hardware, this is a normal server shutdown,
and then turning off from the power supply and turning it on again.
This method is applicable in cases where there has been a
configuration change in libvirt and you need to restart the
instance to apply the new configuration.
"""
self.shutdown(method='NORMAL')
self.start()
def set_autostart(self, *, enabled: bool) -> None:
"""
Set autostart flag for instance.
:param enabled: Bool argument to set or unset autostart flag.
"""
autostart = 1 if enabled else 0
try:
self.domain.setAutostart(autostart)
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot set autostart flag for instance={self.name} '
f'{autostart=}: {e}'
) from e
def set_vcpus(self, nvcpus: int, *, live: bool = False) -> None:
"""
Set vCPU number.
If `live` is True and instance is not currently running vCPUs
will set in config and will applied when instance boot.
NB: Note that if this call is executed before the guest has
finished booting, the guest may fail to process the change.
:param nvcpus: Number of vCPUs
:param live: Affect a running instance
"""
if nvcpus <= 0:
raise InstanceError('Cannot set zero vCPUs')
if nvcpus > self.get_max_vcpus():
raise InstanceError('vCPUs count is greather than max_vcpus')
if nvcpus == self.get_info().nproc:
log.warning(
'Instance instance=%s already have %s vCPUs, nothing to do',
self.name,
nvcpus,
)
return
try:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
self.domain.setVcpusFlags(nvcpus, flags=flags)
if live is True:
if not self.is_running():
log.warning(
'Instance is not running, changes applied in '
'instance config.'
)
return
flags = libvirt.VIR_DOMAIN_AFFECT_LIVE
self.domain.setVcpusFlags(nvcpus, flags=flags)
if self.guest_agent.is_available():
try:
self.guest_agent.raise_for_commands(
['guest-set-vcpus']
)
flags = libvirt.VIR_DOMAIN_VCPU_GUEST
self.domain.setVcpusFlags(nvcpus, flags=flags)
except GuestAgentCommandNotSupportedError:
log.warning(
'Cannot set vCPUs in guest via agent, you may '
'need to apply changes in guest manually.'
)
else:
log.warning(
'Cannot set vCPUs in guest OS on instance=%s. '
'You may need to apply CPUs in guest manually.',
self.name,
)
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot set vCPUs for instance={self.name}: {e}'
) from e
def set_memory(self, memory: int, *, live: bool = False) -> None:
"""
Set memory.
If `live` is True and instance is not currently running set memory
in config and will applied when instance boot.
:param memory: Memory value in mebibytes
:param live: Affect a running instance
"""
if memory <= 0:
raise InstanceError('Cannot set zero memory')
if (memory * 1024) > self.get_max_memory():
raise InstanceError('Memory is greather than max_memory')
if (memory * 1024) == self.get_info().memory:
log.warning(
"Instance '%s' already have %s memory, nothing to do",
self.name,
memory,
)
return
if live and self.is_running():
flags = (
libvirt.VIR_DOMAIN_AFFECT_LIVE
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
)
else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
try:
self.domain.setMemoryFlags(memory * 1024, flags=flags)
except libvirt.libvirtError as e:
msg = f'Cannot set memory for instance={self.name} {memory=}: {e}'
raise InstanceError(msg) from e
def _get_disk_by_target(self, target: str) -> etree.Element:
xml = etree.fromstring(self.dump_xml()) # noqa: S320
child = xml.xpath(f'/domain/devices/disk/target[@dev="{target}"]')
return child[0].getparent() if child else None
def attach_device(
self, device: DeviceConfig, *, live: bool = False
) -> None:
"""
Attach device to compute instance.
:param device: Object with device description e.g. DiskConfig
:param live: Affect a running instance
"""
if live and self.is_running():
flags = (
libvirt.VIR_DOMAIN_AFFECT_LIVE
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
)
else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
if isinstance(device, DiskConfig): # noqa: SIM102
if self._get_disk_by_target(device.target):
log.warning(
"Volume with target '%s' is already attached",
device.target,
)
return
self.domain.attachDeviceFlags(device.to_xml(), flags=flags)
def detach_device(
self, device: DeviceConfig, *, live: bool = False
) -> None:
"""
Dettach device from compute instance.
:param device: Object with device description e.g. DiskConfig
:param live: Affect a running instance
"""
if live and self.is_running():
flags = (
libvirt.VIR_DOMAIN_AFFECT_LIVE
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
)
else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
if isinstance(device, DiskConfig): # noqa: SIM102
if self._get_disk_by_target(device.target) is None:
log.warning(
"Volume with target '%s' is already detached",
device.target,
)
return
self.domain.detachDeviceFlags(device.to_xml(), flags=flags)
def detach_disk(self, name: str) -> None:
"""
Detach disk device by target name.
There is no ``attach_disk()`` method. Use :func:`attach_device`
with :class:`DiskConfig` as argument.
:param name: Disk name e.g. 'vda', 'sda', etc. This name may
not match the name of the disk inside the guest OS.
"""
xml = self._get_disk_by_target(name)
if xml is None:
log.warning(
"Volume with target '%s' is already detached",
name,
)
return
disk_params = {
'disk_type': xml.get('type'),
'source': xml.find('source').get('file'),
'target': xml.find('target').get('dev'),
'readonly': False if xml.find('readonly') is None else True, # noqa: SIM211
}
for param in disk_params:
if disk_params[param] is None:
msg = (
f"Cannot detach volume with target '{name}': "
f"parameter '{param}' is not defined in libvirt XML "
'config on host.'
)
raise InstanceError(msg)
self.detach_device(DiskConfig(**disk_params), live=True)
def resize_disk(
self, name: str, capacity: int, unit: units.DataUnit
) -> None:
"""
Resize attached block device.
:param name: Disk device name e.g. `vda`, `sda`, etc.
:param capacity: New capacity.
:param unit: Capacity unit.
"""
self.domain.blockResize(
name,
units.to_bytes(capacity, unit=unit),
flags=libvirt.VIR_DOMAIN_BLOCK_RESIZE_BYTES,
)
def get_disks(self) -> list[DiskConfig]:
"""Return list of attached disks."""
raise NotImplementedError
def pause(self) -> None:
"""Pause instance."""
if not self.is_running():
raise InstanceError('Cannot pause inactive instance')
self.domain.suspend()
def resume(self) -> None:
"""Resume paused instance."""
self.domain.resume()
def get_ssh_keys(self, user: str) -> list[str]:
"""
Return list of SSH keys on guest for specific user.
:param user: Username.
"""
raise NotImplementedError
def set_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
"""
Add SSH keys to guest for specific user.
:param user: Username.
:param ssh_keys: List of public SSH keys.
"""
raise NotImplementedError
def delete_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
"""
Remove SSH keys from guest for specific user.
:param user: Username.
:param ssh_keys: List of public SSH keys.
"""
raise NotImplementedError
def set_user_password(
self, user: str, password: str, *, encrypted: bool = False
) -> None:
"""
Set new user password in guest OS.
This action performs by guest agent inside the guest.
:param user: Username.
:param password: Password.
:param encrypted: Set it to True if password is already encrypted.
Right encryption method depends on guest OS.
"""
if not self.guest_agent.is_available():
raise InstanceError(
'Cannot change password: guest agent is unavailable'
)
self.guest_agent.raise_for_commands(['guest-set-user-password'])
flags = libvirt.VIR_DOMAIN_PASSWORD_ENCRYPTED if encrypted else 0
self.domain.setUserPassword(user, password, flags=flags)
def dump_xml(self, *, inactive: bool = False) -> str:
"""Return instance XML description."""
flags = libvirt.VIR_DOMAIN_XML_INACTIVE if inactive else 0
return self.domain.XMLDesc(flags)
def delete(self) -> None:
"""Undefine instance."""
# TODO @ge: delete local disks
self.shutdown(method='HARD')
self.domain.undefine()

View File

@ -0,0 +1,165 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Compute instance related objects schemas."""
import re
from enum import StrEnum
from pathlib import Path
from pydantic import BaseModel, Extra, validator
from compute.utils.units import DataUnit
class EntityModel(BaseModel):
"""Basic entity model."""
class Config:
"""Do not allow extra fields."""
extra = Extra.forbid
class CPUEmulationMode(StrEnum):
"""CPU emulation mode enumerated."""
HOST_PASSTHROUGH = 'host-passthrough'
HOST_MODEL = 'host-model'
CUSTOM = 'custom'
MAXIMUM = 'maximum'
class CPUTopologySchema(EntityModel):
"""CPU topology model."""
sockets: int
cores: int
threads: int
dies: int = 1
class CPUFeaturesSchema(EntityModel):
"""CPU features model."""
require: list[str]
disable: list[str]
class CPUSchema(EntityModel):
"""CPU model."""
emulation_mode: CPUEmulationMode
model: str | None
vendor: str | None
topology: CPUTopologySchema | None
features: CPUFeaturesSchema | None
class VolumeType(StrEnum):
"""Storage volume types enumeration."""
FILE = 'file'
class VolumeCapacitySchema(EntityModel):
"""Storage volume capacity field model."""
value: int
unit: DataUnit
class VolumeSchema(EntityModel):
"""Storage volume model."""
type: VolumeType # noqa: A003
target: str
capacity: VolumeCapacitySchema
source: str | None = None
is_readonly: bool = False
is_system: bool = False
class NetworkInterfaceSchema(EntityModel):
"""Network inerface model."""
source: str
mac: str
class BootOptionsSchema(EntityModel):
"""Instance boot settings."""
order: tuple
class InstanceSchema(EntityModel):
"""Compute instance model."""
name: str
title: str | None
description: str | None
memory: int
max_memory: int
vcpus: int
max_vcpus: int
cpu: CPUSchema
machine: str
emulator: Path
arch: str
boot: BootOptionsSchema
volumes: list[VolumeSchema]
network_interfaces: list[NetworkInterfaceSchema]
image: str | None = None
@validator('name')
def _check_name(cls, value: str) -> str: # noqa: N805
if not re.match(r'^[a-z0-9_]+$', value):
msg = (
'Name can contain only lowercase letters, numbers '
'and underscore.'
)
raise ValueError(msg)
return value
@validator('cpu')
def _check_topology(cls, cpu: int, values: dict) -> CPUSchema: # noqa: N805
topo = cpu.topology
max_vcpus = values['max_vcpus']
if topo and topo.sockets * topo.cores * topo.threads != max_vcpus:
msg = f'CPU topology does not match with {max_vcpus=}'
raise ValueError(msg)
return cpu
@validator('volumes')
def _check_volumes(cls, volumes: list) -> list: # noqa: N805
if len([v for v in volumes if v.is_system is True]) != 1:
msg = 'volumes list must contain one system volume'
raise ValueError(msg)
vol_with_source = 0
for vol in volumes:
if vol.is_system is True and vol.is_readonly is True:
msg = 'volume marked as system cannot be readonly'
raise ValueError(msg)
if vol.source is not None:
vol_with_source += 1
return volumes
@validator('network_interfaces')
def _check_network_interfaces(cls, value: list) -> list: # noqa: N805
if not value:
msg = 'Network interfaces list must contain at least one element'
raise ValueError(msg)
return value

View File

@ -0,0 +1,286 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Hypervisor session manager."""
import logging
import os
from contextlib import AbstractContextManager
from types import TracebackType
from typing import Any, NamedTuple
from uuid import uuid4
import libvirt
from lxml import etree
from .exceptions import (
InstanceNotFoundError,
SessionError,
StoragePoolNotFoundError,
)
from .instance import Instance, InstanceConfig, InstanceSchema
from .storage import DiskConfig, StoragePool, VolumeConfig
from .utils import units
log = logging.getLogger(__name__)
class Capabilities(NamedTuple):
"""Store domain capabilities info."""
arch: str
virt_type: str
emulator: str
machine: str
max_vcpus: int
cpu_vendor: str
cpu_model: str
cpu_features: dict
usable_cpus: list[dict]
class NodeInfo(NamedTuple):
"""
Store compute node info.
See https://libvirt.org/html/libvirt-libvirt-host.html#virNodeInfo
NOTE: memory unit in libvirt docs is wrong! Actual unit is MiB.
"""
arch: str
memory: int
cpus: int
mhz: int
nodes: int
sockets: int
cores: int
threads: int
class Session(AbstractContextManager):
"""
Hypervisor session context manager.
:cvar IMAGES_POOL: images storage pool name taken from env
:cvar VOLUMES_POOL: volumes storage pool name taken from env
"""
IMAGES_POOL = os.getenv('CMP_IMAGES_POOL')
VOLUMES_POOL = os.getenv('CMP_VOLUMES_POOL')
def __init__(self, uri: str | None = None):
"""
Initialise session with hypervisor.
:ivar str uri: libvirt connection URI.
:ivar libvirt.virConnect connection: libvirt connection object.
:param uri: libvirt connection URI.
"""
self.uri = uri or 'qemu:///system'
self.connection = libvirt.open(self.uri)
def __enter__(self):
"""Return Session object."""
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
exc_traceback: TracebackType | None,
):
"""Close the connection when leaving the context."""
self.close()
def close(self) -> None:
"""Close connection to libvirt daemon."""
self.connection.close()
def get_node_info(self) -> NodeInfo:
"""Return information about compute node."""
info = self.connection.getInfo()
return NodeInfo(
arch=info[0],
memory=info[1],
cpus=info[2],
mhz=info[3],
nodes=info[4],
sockets=info[5],
cores=info[6],
threads=info[7],
)
def _cap_get_usable_cpus(self, xml: etree.Element) -> list[dict]:
x = xml.xpath('/domainCapabilities/cpu/mode[@name="custom"]')[0]
cpus = []
for cpu in x.findall('model'):
if cpu.get('usable') == 'yes':
cpus.append( # noqa: PERF401
{
'vendor': cpu.get('vendor'),
'model': cpu.text,
}
)
return cpus
def _cap_get_cpu_features(self, xml: etree.Element) -> dict:
x = xml.xpath('/domainCapabilities/cpu/mode[@name="host-model"]')[0]
require = []
disable = []
for feature in x.findall('feature'):
policy = feature.get('policy')
name = feature.get('name')
if policy == 'require':
require.append(name)
if policy == 'disable':
disable.append(name)
return {'require': require, 'disable': disable}
def get_capabilities(self) -> Capabilities:
"""Return capabilities e.g. arch, virt, emulator, etc."""
prefix = '/domainCapabilities'
hprefix = f'{prefix}/cpu/mode[@name="host-model"]'
caps = etree.fromstring(self.connection.getDomainCapabilities()) # noqa: S320
return Capabilities(
arch=caps.xpath(f'{prefix}/arch/text()')[0],
virt_type=caps.xpath(f'{prefix}/domain/text()')[0],
emulator=caps.xpath(f'{prefix}/path/text()')[0],
machine=caps.xpath(f'{prefix}/machine/text()')[0],
max_vcpus=int(caps.xpath(f'{prefix}/vcpu/@max')[0]),
cpu_vendor=caps.xpath(f'{hprefix}/vendor/text()')[0],
cpu_model=caps.xpath(f'{hprefix}/model/text()')[0],
cpu_features=self._cap_get_cpu_features(caps),
usable_cpus=self._cap_get_cpus(caps),
)
def create_instance(self, **kwargs: Any) -> Instance:
"""
Create and return new compute instance.
:param name: Instance name.
:type name: str
:param title: Instance title for humans.
:type title: str
:param description: Some information about instance.
:type description: str
:param memory: Memory in MiB.
:type memory: int
:param max_memory: Maximum memory in MiB.
:type max_memory: int
:param vcpus: Number of vCPUs.
:type vcpus: int
:param max_vcpus: Maximum vCPUs.
:type max_vcpus: int
:param cpu: CPU configuration. See :class:`CPUSchema` for info.
:type cpu: dict
:param machine: QEMU emulated machine.
:type machine: str
:param emulator: Path to emulator.
:type emulator: str
:param arch: CPU architecture to virtualization.
:type arch: str
:param boot: Boot settings. See :class:`BootOptionsSchema`.
:type boot: dict
:param image: Source disk image name for system disk.
:type image: str
:param volumes: List of storage volume configs. For more info
see :class:`VolumeSchema`.
:type volumes: list[dict]
:param network_interfaces: List of virtual network interfaces
configs. See :class:`NetworkInterfaceSchema` for more info.
:type network_interfaces: list[dict]
"""
data = InstanceSchema(**kwargs)
config = InstanceConfig(data)
log.info('Define XML...')
log.info(config.to_xml())
self.connection.defineXML(config.to_xml())
log.info('Getting instance...')
instance = self.get_instance(config.name)
log.info('Creating volumes...')
for volume in data.volumes:
log.info('Creating volume=%s', volume)
capacity = units.to_bytes(
volume.capacity.value, volume.capacity.unit
)
log.info('Connecting to images pool...')
images_pool = self.get_storage_pool(self.IMAGES_POOL)
log.info('Connecting to volumes pool...')
volumes_pool = self.get_storage_pool(self.VOLUMES_POOL)
log.info('Building volume configuration...')
if not volume.source:
vol_name = f'{uuid4()}.qcow2'
else:
vol_name = volume.source
vol_conf = VolumeConfig(
name=vol_name,
path=str(volumes_pool.path.joinpath(vol_name)),
capacity=capacity,
)
log.info('Volume configuration is:\n %s', vol_conf.to_xml())
if volume.is_system is True and data.image:
log.info(
"Volume is marked as 'system', start cloning image..."
)
log.info('Get image %s', data.image)
image = images_pool.get_volume(data.image)
log.info('Cloning image into volumes pool...')
vol = volumes_pool.clone_volume(image, vol_conf)
log.info(
'Resize cloned volume to specified size: %s',
capacity,
)
vol.resize(capacity, unit=units.DataUnit.BYTES)
else:
log.info('Create volume...')
volumes_pool.create_volume(vol_conf)
log.info('Attaching volume to instance...')
instance.attach_device(
DiskConfig(
disk_type=volume.type,
source=vol_conf.path,
target=volume.target,
readonly=volume.is_readonly,
)
)
return instance
def get_instance(self, name: str) -> Instance:
"""Get compute instance by name."""
try:
return Instance(self.connection.lookupByName(name))
except libvirt.libvirtError as e:
if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
raise InstanceNotFoundError(name) from e
raise SessionError(e) from e
def list_instances(self) -> list[Instance]:
"""List all instances."""
return [Instance(dom) for dom in self.connection.listAllDomains()]
def get_storage_pool(self, name: str) -> StoragePool:
"""Get storage pool by name."""
try:
return StoragePool(self.connection.storagePoolLookupByName(name))
except libvirt.libvirtError as e:
if e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_POOL:
raise StoragePoolNotFoundError(name) from e
raise SessionError(e) from e
def list_storage_pools(self) -> list[StoragePool]:
"""List all strage pools."""
return [StoragePool(p) for p in self.connection.listStoragePools()]

View File

@ -0,0 +1,17 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from .pool import StoragePool
from .volume import DiskConfig, Volume, VolumeConfig

View File

@ -0,0 +1,124 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage storage pools."""
import logging
from pathlib import Path
from typing import NamedTuple
import libvirt
from lxml import etree
from compute.exceptions import StoragePoolError, VolumeNotFoundError
from .volume import Volume, VolumeConfig
log = logging.getLogger(__name__)
class StoragePoolUsageInfo(NamedTuple):
"""Storage pool usage info."""
capacity: int
allocation: int
available: int
class StoragePool:
"""Storage pool manipulating class."""
def __init__(self, pool: libvirt.virStoragePool):
"""Initislise StoragePool."""
self.pool = pool
self.name = pool.name()
self.path = self._get_path()
def _get_path(self) -> Path:
"""Return storage pool path."""
xml = etree.fromstring(self.pool.XMLDesc()) # noqa: S320
return Path(xml.xpath('/pool/target/path/text()')[0])
def get_usage_info(self) -> StoragePoolUsageInfo:
"""Return info about storage pool usage."""
xml = etree.fromstring(self.pool.XMLDesc()) # noqa: S320
return StoragePoolUsageInfo(
capacity=int(xml.xpath('/pool/capacity/text()')[0]),
allocation=int(xml.xpath('/pool/allocation/text()')[0]),
available=int(xml.xpath('/pool/available/text()')[0]),
)
def dump_xml(self) -> str:
"""Return storage pool XML description as string."""
return self.pool.XMLDesc()
def refresh(self) -> None:
"""Refresh storage pool."""
# TODO @ge: handle libvirt asynchronous job related exceptions
self.pool.refresh()
def create_volume(self, vol_conf: VolumeConfig) -> Volume:
"""Create storage volume and return Volume instance."""
log.info(
'Create storage volume vol=%s in pool=%s', vol_conf.name, self.name
)
vol = self.pool.createXML(
vol_conf.to_xml(),
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
)
return Volume(self.pool, vol)
def clone_volume(self, src: Volume, dst: VolumeConfig) -> Volume:
"""
Make storage volume copy.
:param src: Input volume
:param dst: Output volume config
"""
log.info(
'Start volume cloning '
'src_pool=%s src_vol=%s dst_pool=%s dst_vol=%s',
src.pool_name,
src.name,
self.pool.name,
dst.name,
)
vol = self.pool.createXMLFrom(
dst.to_xml(), # new volume XML description
src.vol, # source volume virStorageVol object
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
)
if vol is None:
raise StoragePoolError
return Volume(self.pool, vol)
def get_volume(self, name: str) -> Volume | None:
"""Lookup and return Volume instance or None."""
log.info(
'Lookup for storage volume vol=%s in pool=%s', name, self.pool.name
)
try:
vol = self.pool.storageVolLookupByName(name)
return Volume(self.pool, vol)
except libvirt.libvirtError as e:
if e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL:
raise VolumeNotFoundError(name) from e
log.exception('unexpected error from libvirt')
raise StoragePoolError(e) from e
def list_volumes(self) -> list[Volume]:
"""Return list of volumes in storage pool."""
return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()]

View File

@ -0,0 +1,138 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage storage volumes."""
from dataclasses import dataclass
from pathlib import Path
from time import time
import libvirt
from lxml import etree
from lxml.builder import E
from compute.common import DeviceConfig, EntityConfig
from compute.utils import units
@dataclass
class VolumeConfig(EntityConfig):
"""
Storage volume XML config builder.
Generate XML config for creating a volume in a libvirt
storage pool.
"""
name: str
path: str
capacity: int
def to_xml(self) -> str:
"""Return XML config for libvirt."""
unixtime = str(int(time()))
xml = E.volume(type='file')
xml.append(E.name(self.name))
xml.append(E.key(self.path))
xml.append(E.source())
xml.append(E.capacity(str(self.capacity), unit='bytes'))
xml.append(E.allocation('0'))
xml.append(
E.target(
E.path(self.path),
E.format(type='qcow2'),
E.timestamps(
E.atime(unixtime), E.mtime(unixtime), E.ctime(unixtime)
),
E.compat('1.1'),
E.features(E.lazy_refcounts()),
)
)
return etree.tostring(xml, encoding='unicode', pretty_print=True)
@dataclass
class DiskConfig(DeviceConfig):
"""
Disk XML config builder.
Generate XML config for attaching or detaching storage volumes
to compute instances.
"""
disk_type: str
source: str | Path
target: str
readonly: bool = False
def to_xml(self) -> str:
"""Return XML config for libvirt."""
xml = E.disk(type=self.disk_type, device='disk')
xml.append(E.driver(name='qemu', type='qcow2', cache='writethrough'))
if self.disk_type == 'file':
xml.append(E.source(file=str(self.source)))
xml.append(E.target(dev=self.target, bus='virtio'))
if self.readonly:
xml.append(E.readonly())
return etree.tostring(xml, encoding='unicode', pretty_print=True)
class Volume:
"""Storage volume manipulating class."""
def __init__(
self, pool: libvirt.virStoragePool, vol: libvirt.virStorageVol
):
"""
Initialise Volume.
:param pool: libvirt virStoragePool object
:param vol: libvirt virStorageVol object
"""
self.pool = pool
self.pool_name = pool.name()
self.vol = vol
self.name = vol.name()
self.path = Path(vol.path())
def dump_xml(self) -> str:
"""Return volume XML description as string."""
return self.vol.XMLDesc()
def clone(self, vol_conf: VolumeConfig) -> None:
"""
Make a copy of volume to the same storage pool.
:param vol_info VolumeInfo: New storage volume dataclass object
"""
self.pool.createXMLFrom(
vol_conf.to_xml(),
self.vol,
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
)
def resize(self, capacity: int, unit: units.DataUnit) -> None:
"""
Resize volume.
:param capacity int: Volume new capacity.
:param unit DataUnit: Data unit. Internally converts into bytes.
"""
# TODO @ge: Check actual volume size before resize
self.vol.resize(units.to_bytes(capacity, unit=unit))
def delete(self) -> None:
"""Delete volume from storage pool."""
self.vol.delete()

View File

@ -0,0 +1,56 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Configuration loader."""
import tomllib
from collections import UserDict
from pathlib import Path
from compute.exceptions import ConfigLoaderError
DEFAULT_CONFIGURATION = {}
DEFAULT_CONFIG_FILE = '/etc/computed/computed.toml'
class ConfigLoader(UserDict):
"""UserDict for storing configuration."""
def __init__(self, file: Path | None = None):
"""
Initialise ConfigLoader.
:param file: Path to configuration file. If `file` is None
use default path from DEFAULT_CONFIG_FILE constant.
"""
# TODO @ge: load deafult configuration
self.file = Path(file) if file else Path(DEFAULT_CONFIG_FILE)
super().__init__(self.load())
def load(self) -> dict:
"""Load confguration object from TOML file."""
try:
with Path(self.file).open('rb') as configfile:
return tomllib.load(configfile)
# TODO @ge: add config schema validation
except tomllib.TOMLDecodeError as tomlerr:
raise ConfigLoaderError(
f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
) from tomlerr
except (OSError, ValueError) as readerr:
raise ConfigLoaderError(
f'Cannot read config file: {self.file}: {readerr}'
) from readerr

View File

@ -0,0 +1,33 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Random identificators."""
# ruff: noqa: S311, C417
import random
def random_mac() -> str:
"""Retrun random MAC address."""
mac = [
0x00,
0x16,
0x3E,
random.randint(0x00, 0x7F),
random.randint(0x00, 0xFF),
random.randint(0x00, 0xFF),
]
return ':'.join(map(lambda x: '%02x' % x, mac))

View File

@ -0,0 +1,54 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Tools for data units convertion."""
from enum import StrEnum
class DataUnit(StrEnum):
"""Data units enumerated."""
BYTES = 'bytes'
KIB = 'KiB'
MIB = 'MiB'
GIB = 'GiB'
TIB = 'TiB'
class InvalidDataUnitError(ValueError):
"""Data unit is not valid."""
def __init__(self, msg: str):
"""Initialise InvalidDataUnitError."""
super().__init__(
f'{msg}, valid units are: {", ".join(list(DataUnit))}'
)
def to_bytes(value: int, unit: DataUnit = DataUnit.BYTES) -> int:
"""Convert value to bytes. See :class:`DataUnit`."""
try:
_ = DataUnit(unit)
except ValueError as e:
raise InvalidDataUnitError(e) from e
powers = {
DataUnit.BYTES: 0,
DataUnit.KIB: 1,
DataUnit.MIB: 2,
DataUnit.GIB: 3,
DataUnit.TIB: 4,
}
return value * pow(1024, powers[unit])

View File

@ -0,0 +1,8 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from compute.cli.control import cli
if __name__ == "__main__":
sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
sys.exit(cli())

View File

@ -0,0 +1,81 @@
Metadata-Version: 2.1
Name: compute
Version: 0.1.0.dev1
Summary: Compute instances management library and tools
Author: ge
Author-email: ge@nixhacks.net
Requires-Python: >=3.11,<4.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Requires-Dist: libvirt-python (==9.0.0)
Requires-Dist: lxml (>=4.9.2,<5.0.0)
Requires-Dist: pydantic (==1.10.4)
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
Description-Content-Type: text/markdown
# Compute
Compute instances management library and tools.
## Docs
Run `make serve-docs`. See [Development](#development) below.
## Roadmap
- [x] Create instances
- [ ] CDROM
- [ ] cloud-init for provisioning instances
- [x] Instance power management
- [x] Instance pause and resume
- [x] vCPU hotplug
- [x] Memory hotplug
- [x] Hot disk resize [not tested]
- [ ] CPU topology customization
- [x] CPU customization (emulation mode, model, vendor, features)
- [ ] BIOS/UEFI settings
- [x] Device attaching
- [x] Device detaching
- [ ] GPU passthrough
- [ ] CPU guarantied resource percent support
- [x] QEMU Guest Agent management
- [ ] Instance resources usage stats
- [ ] SSH-keys management
- [x] Setting user passwords in guest
- [x] QCOW2 disks support
- [ ] ZVOL support
- [ ] Network disks support
- [ ] Images service integration (Images service is not implemented yet)
- [ ] Manage storage pools
- [ ] Idempotency
- [ ] CLI [in progress]
- [ ] HTTP API
- [ ] Instance migrations
- [ ] Instance snapshots
- [ ] Instance backups
- [ ] LXC
## Development
Python 3.11+ is required.
Install [poetry](https://python-poetry.org/), clone this repository and run:
```
poetry install --with dev --with docs
```
# Build Debian package
Install Docker first, then run:
```
make build-deb
```
`compute` and `compute-doc` packages will built. See packaging/build directory. Packages can be installed via `dpkg` or `apt-get`:
```
apt-get install ./compute*.deb
```

View File

@ -0,0 +1,65 @@
# Compute
Compute instances management library and tools.
## Docs
Run `make serve-docs`. See [Development](#development) below.
## Roadmap
- [x] Create instances
- [ ] CDROM
- [ ] cloud-init for provisioning instances
- [x] Instance power management
- [x] Instance pause and resume
- [x] vCPU hotplug
- [x] Memory hotplug
- [x] Hot disk resize [not tested]
- [ ] CPU topology customization
- [x] CPU customization (emulation mode, model, vendor, features)
- [ ] BIOS/UEFI settings
- [x] Device attaching
- [x] Device detaching
- [ ] GPU passthrough
- [ ] CPU guarantied resource percent support
- [x] QEMU Guest Agent management
- [ ] Instance resources usage stats
- [ ] SSH-keys management
- [x] Setting user passwords in guest
- [x] QCOW2 disks support
- [ ] ZVOL support
- [ ] Network disks support
- [ ] Images service integration (Images service is not implemented yet)
- [ ] Manage storage pools
- [ ] Idempotency
- [ ] CLI [in progress]
- [ ] HTTP API
- [ ] Instance migrations
- [ ] Instance snapshots
- [ ] Instance backups
- [ ] LXC
## Development
Python 3.11+ is required.
Install [poetry](https://python-poetry.org/), clone this repository and run:
```
poetry install --with dev --with docs
```
# Build Debian package
Install Docker first, then run:
```
make build-deb
```
`compute` and `compute-doc` packages will built. See packaging/build directory. Packages can be installed via `dpkg` or `apt-get`:
```
apt-get install ./compute*.deb
```

View File

@ -0,0 +1,22 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Compute instances management library."""
__version__ = '0.1.0-dev1'
from .instance import Instance, InstanceConfig, InstanceSchema
from .session import Session
from .storage import StoragePool, Volume, VolumeConfig

View File

@ -0,0 +1,21 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Command line interface for compute module."""
from compute.cli import main
main.cli()

View File

@ -0,0 +1,501 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Command line interface."""
import argparse
import io
import logging
import os
import shlex
import sys
from collections import UserDict
from typing import Any
from uuid import uuid4
import libvirt
import yaml
from pydantic import ValidationError
from compute import __version__
from compute.exceptions import ComputeError, GuestAgentTimeoutExceededError
from compute.instance import GuestAgent
from compute.session import Session
from compute.utils import ids
log = logging.getLogger(__name__)
log_levels = [lv.lower() for lv in logging.getLevelNamesMapping()]
libvirt.registerErrorHandler(
lambda userdata, err: None, # noqa: ARG005
ctx=None,
)
class Table:
"""Minimalistic text table constructor."""
def __init__(self, whitespace: str | None = None):
"""Initialise Table."""
self.whitespace = whitespace or '\t'
self.header = []
self.rows = []
self.table = ''
def add_row(self, row: list) -> None:
"""Add table row."""
self.rows.append([str(col) for col in row])
def add_rows(self, rows: list[list]) -> None:
"""Add multiple rows."""
for row in rows:
self.add_row(row)
def __str__(self) -> str:
"""Build table and return."""
widths = [max(map(len, col)) for col in zip(*self.rows, strict=True)]
self.rows.insert(0, [str(h).upper() for h in self.header])
for row in self.rows:
self.table += self.whitespace.join(
(
val.ljust(width)
for val, width in zip(row, widths, strict=True)
)
)
self.table += '\n'
return self.table.strip()
def _list_instances(session: Session) -> None:
table = Table()
table.header = ['NAME', 'STATE']
for instance in session.list_instances():
table.add_row(
[
instance.name,
instance.get_status(),
]
)
print(table)
sys.exit()
def _exec_guest_agent_command(
session: Session, args: argparse.Namespace
) -> None:
instance = session.get_instance(args.instance)
ga = GuestAgent(instance.domain, timeout=args.timeout)
arguments = args.arguments.copy()
if len(arguments) > 1 and not args.no_join_args:
arguments = [shlex.join(arguments)]
if not args.no_join_args:
arguments.insert(0, '-c')
stdin = None
if not sys.stdin.isatty():
stdin = sys.stdin.read()
try:
output = ga.guest_exec(
path=args.executable,
args=arguments,
env=args.env,
stdin=stdin,
capture_output=True,
decode_output=True,
poll=True,
)
except GuestAgentTimeoutExceededError as e:
sys.exit(
f'{e}. NOTE: command may still running in guest, '
f'PID={ga.last_pid}'
)
if output.stderr:
print(output.stderr.strip(), file=sys.stderr)
if output.stdout:
print(output.stdout.strip(), file=sys.stdout)
sys.exit(output.exitcode)
class _NotPresent:
"""
Type for representing non-existent dictionary keys.
See :class:`_FillableDict`.
"""
class _FillableDict(UserDict):
"""Use :method:`fill` to add key if not present."""
def __init__(self, data: dict):
self.data = data
def fill(self, key: str, value: Any) -> None: # noqa: ANN401
if self.data.get(key, _NotPresent) is _NotPresent:
self.data[key] = value
def _merge_dicts(a: dict, b: dict, path: list[str] | None = None) -> dict:
"""Merge `b` into `a`. Return modified `a`."""
if path is None:
path = []
for key in b:
if key in a:
if isinstance(a[key], dict) and isinstance(b[key], dict):
_merge_dicts(a[key], b[key], [path + str(key)])
elif a[key] == b[key]:
pass # same leaf value
else:
a[key] = b[key] # replace existing key's values
else:
a[key] = b[key]
return a
def _create_instance(session: Session, file: io.TextIOWrapper) -> None:
try:
data = _FillableDict(yaml.load(file.read(), Loader=yaml.SafeLoader))
log.debug('Read from file: %s', data)
except yaml.YAMLError as e:
sys.exit(f'error: cannot parse YAML: {e}')
capabilities = session.get_capabilities()
node_info = session.get_node_info()
data.fill('name', uuid4().hex)
data.fill('title', None)
data.fill('description', None)
data.fill('arch', capabilities.arch)
data.fill('machine', capabilities.machine)
data.fill('emulator', capabilities.emulator)
data.fill('max_vcpus', node_info.cpus)
data.fill('max_memory', node_info.memory)
data.fill('cpu', {})
cpu = {
'emulation_mode': 'host-passthrough',
'model': None,
'vendor': None,
'topology': None,
'features': None,
}
data['cpu'] = _merge_dicts(data['cpu'], cpu)
data.fill(
'network_interfaces',
[{'source': 'default', 'mac': ids.random_mac()}],
)
data.fill('boot', {'order': ['cdrom', 'hd']})
try:
log.debug('Input data: %s', data)
session.create_instance(**data)
except ValidationError as e:
for error in e.errors():
fields = '.'.join([str(lc) for lc in error['loc']])
print(
f"validation error: {fields}: {error['msg']}",
file=sys.stderr,
)
sys.exit()
def _shutdown_instance(session: Session, args: argparse.Namespace) -> None:
instance = session.get_instance(args.instance)
if args.soft:
method = 'SOFT'
elif args.hard:
method = 'HARD'
elif args.unsafe:
method = 'UNSAFE'
else:
method = 'NORMAL'
instance.shutdown(method)
def main(session: Session, args: argparse.Namespace) -> None:
"""Perform actions."""
match args.command:
case 'init':
_create_instance(session, args.file)
case 'exec':
_exec_guest_agent_command(session, args)
case 'ls':
_list_instances(session)
case 'start':
instance = session.get_instance(args.instance)
instance.start()
case 'shutdown':
_shutdown_instance(session, args)
case 'reboot':
instance = session.get_instance(args.instance)
instance.reboot()
case 'reset':
instance = session.get_instance(args.instance)
instance.reset()
case 'powrst':
instance = session.get_instance(args.instance)
instance.power_reset()
case 'pause':
instance = session.get_instance(args.instance)
instance.pause()
case 'resume':
instance = session.get_instance(args.instance)
instance.resume()
case 'status':
instance = session.get_instance(args.instance)
print(instance.status)
case 'setvcpus':
instance = session.get_instance(args.instance)
instance.set_vcpus(args.nvcpus, live=True)
case 'setmem':
instance = session.get_instance(args.instance)
instance.set_memory(args.memory, live=True)
case 'setpass':
instance = session.get_instance(args.instance)
instance.set_user_password(
args.username,
args.password,
encrypted=args.encrypted,
)
def cli() -> None: # noqa: PLR0915
"""Return command line arguments parser."""
root = argparse.ArgumentParser(
prog='compute',
description='manage compute instances',
formatter_class=argparse.RawTextHelpFormatter,
)
root.add_argument(
'-v',
'--verbose',
action='store_true',
default=False,
help='enable verbose mode',
)
root.add_argument(
'-c',
'--connect',
metavar='URI',
help='libvirt connection URI',
)
root.add_argument(
'-l',
'--log-level',
type=str.lower,
metavar='LEVEL',
choices=log_levels,
help='log level',
)
root.add_argument(
'-V',
'--version',
action='version',
version=__version__,
)
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
# init command
init = subparsers.add_parser(
'init', help='initialise instance using YAML config file'
)
init.add_argument(
'file',
type=argparse.FileType('r', encoding='UTF-8'),
nargs='?',
default='instance.yaml',
help='instance config [default: instance.yaml]',
)
# exec subcommand
execute = subparsers.add_parser(
'exec',
help='execute command in guest via guest agent',
description=(
'NOTE: any argument after instance name will be passed into '
'guest as shell command.'
),
)
execute.add_argument('instance')
execute.add_argument('arguments', nargs=argparse.REMAINDER)
execute.add_argument(
'-t',
'--timeout',
type=int,
default=60,
help=(
'waiting time in seconds for a command to be executed '
'in guest [default: 60]'
),
)
execute.add_argument(
'-x',
'--executable',
default='/bin/sh',
help='path to executable in guest [default: /bin/sh]',
)
execute.add_argument(
'-e',
'--env',
type=str,
nargs='?',
action='append',
help='environment variables to pass to executable in guest',
)
execute.add_argument(
'-n',
'--no-join-args',
action='store_true',
default=False,
help=(
"do not join arguments list and add '-c' option, suitable "
'for non-shell executables and other specific cases.'
),
)
# ls subcommand
listall = subparsers.add_parser('ls', help='list instances')
listall.add_argument(
'-a',
'--all',
action='store_true',
default=False,
help='list all instances including inactive',
)
# start subcommand
start = subparsers.add_parser('start', help='start instance')
start.add_argument('instance')
# shutdown subcommand
shutdown = subparsers.add_parser('shutdown', help='shutdown instance')
shutdown.add_argument('instance')
shutdown_opts = shutdown.add_mutually_exclusive_group()
shutdown_opts.add_argument(
'-s',
'--soft',
action='store_true',
help='normal guest OS shutdown, guest agent is used',
)
shutdown_opts.add_argument(
'-n',
'--normal',
action='store_true',
help='shutdown with hypervisor selected method [default]',
)
shutdown_opts.add_argument(
'-H',
'--hard',
action='store_true',
help=(
"gracefully destroy instance, it's like long "
'pressing the power button'
),
)
shutdown_opts.add_argument(
'-u',
'--unsafe',
action='store_true',
help=(
'destroy instance, this is similar to a power outage '
'and may result in data loss or corruption'
),
)
# reboot subcommand
reboot = subparsers.add_parser('reboot', help='reboot instance')
reboot.add_argument('instance')
# reset subcommand
reset = subparsers.add_parser('reset', help='reset instance')
reset.add_argument('instance')
# powrst subcommand
powrst = subparsers.add_parser('powrst', help='power reset instance')
powrst.add_argument('instance')
# pause subcommand
pause = subparsers.add_parser('pause', help='pause instance')
pause.add_argument('instance')
# resume subcommand
resume = subparsers.add_parser('resume', help='resume paused instance')
resume.add_argument('instance')
# status subcommand
status = subparsers.add_parser('status', help='display instance status')
status.add_argument('instance')
# setvcpus subcommand
setvcpus = subparsers.add_parser('setvcpus', help='set vCPU number')
setvcpus.add_argument('instance')
setvcpus.add_argument('nvcpus', type=int)
# setmem subcommand
setmem = subparsers.add_parser('setmem', help='set memory size')
setmem.add_argument('instance')
setmem.add_argument('memory', type=int, help='memory in MiB')
# setpass subcommand
setpass = subparsers.add_parser(
'setpass',
help='set user password in guest',
)
setpass.add_argument('instance')
setpass.add_argument('username')
setpass.add_argument('password')
setpass.add_argument(
'-e',
'--encrypted',
action='store_true',
default=False,
help='set it if password is already encrypted',
)
args = root.parse_args()
if args.command is None:
root.print_help()
sys.exit()
log_level = args.log_level or os.getenv('CMP_LOG')
if isinstance(log_level, str) and log_level.lower() in log_levels:
logging.basicConfig(
level=logging.getLevelNamesMapping()[log_level.upper()]
)
log.debug('CLI started with args: %s', args)
connect_uri = (
args.connect
or os.getenv('CMP_LIBVIRT_URI')
or os.getenv('LIBVIRT_DEFAULT_URI')
or 'qemu:///system'
)
try:
with Session(connect_uri) as session:
main(session, args)
except ComputeError as e:
sys.exit(f'error: {e}')
except KeyboardInterrupt:
sys.exit()
except SystemExit as e:
sys.exit(e)
except Exception as e: # noqa: BLE001
sys.exit(f'unexpected error {type(e)}: {e}')
if __name__ == '__main__':
cli()

View File

@ -0,0 +1,30 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Common symbols."""
from abc import ABC, abstractmethod
class EntityConfig(ABC):
"""An abstract entity XML config builder class."""
@abstractmethod
def to_xml(self) -> str:
"""Return device XML config."""
raise NotImplementedError
DeviceConfig = EntityConfig

View File

@ -0,0 +1,80 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Exceptions."""
class ComputeError(Exception):
"""Basic exception class."""
class ConfigLoaderError(ComputeError):
"""Something went wrong when loading configuration."""
class SessionError(ComputeError):
"""Something went wrong while connecting to libvirtd."""
class GuestAgentError(ComputeError):
"""Something went wring when QEMU Guest Agent call."""
class GuestAgentUnavailableError(GuestAgentError):
"""Guest agent is not connected or is unavailable."""
class GuestAgentTimeoutExceededError(GuestAgentError):
"""QEMU timeout exceeded."""
def __init__(self, msg: int):
"""Initialise GuestAgentTimeoutExceededError."""
super().__init__(f'QEMU timeout ({msg} sec) exceeded')
class GuestAgentCommandNotSupportedError(GuestAgentError):
"""Guest agent command is not supported or blacklisted on guest."""
class StoragePoolError(ComputeError):
"""Something went wrong when operating with storage pool."""
class StoragePoolNotFoundError(StoragePoolError):
"""Storage pool not found."""
def __init__(self, msg: str):
"""Initialise StoragePoolNotFoundError."""
super().__init__(f"storage pool named '{msg}' not found")
class VolumeNotFoundError(StoragePoolError):
"""Storage volume not found."""
def __init__(self, msg: str):
"""Initialise VolumeNotFoundError."""
super().__init__(f"storage volume '{msg}' not found")
class InstanceError(ComputeError):
"""Something went wrong while interacting with the domain."""
class InstanceNotFoundError(InstanceError):
"""Virtual machine or container not found on compute node."""
def __init__(self, msg: str):
"""Initialise InstanceNotFoundError."""
super().__init__(f"compute instance '{msg}' not found")

View File

@ -0,0 +1,18 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from .guest_agent import GuestAgent
from .instance import Instance, InstanceConfig
from .schemas import InstanceSchema

View File

@ -0,0 +1,208 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Interacting with the QEMU Guest Agent."""
import json
import logging
from base64 import b64decode, standard_b64encode
from time import sleep, time
from typing import NamedTuple
import libvirt
import libvirt_qemu
from compute.exceptions import (
GuestAgentCommandNotSupportedError,
GuestAgentError,
GuestAgentTimeoutExceededError,
GuestAgentUnavailableError,
)
log = logging.getLogger(__name__)
class GuestExecOutput(NamedTuple):
"""QEMU guest-exec command output."""
exited: bool | None = None
exitcode: int | None = None
stdout: str | None = None
stderr: str | None = None
class GuestAgent:
"""Class for interacting with QEMU guest agent."""
def __init__(self, domain: libvirt.virDomain, timeout: int = 60):
"""
Initialise GuestAgent.
:param domain: Libvirt domain object
:param timeout: QEMU timeout
"""
self.domain = domain
self.timeout = timeout
self.flags = libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
self.last_pid = None
def execute(self, command: dict) -> dict:
"""
Execute QEMU guest agent command.
See: https://qemu-project.gitlab.io/qemu/interop/qemu-ga-ref.html
:param command: QEMU guest agent command as dict
:return: Command output
:rtype: dict
"""
log.debug(command)
try:
output = libvirt_qemu.qemuAgentCommand(
self.domain, json.dumps(command), self.timeout, self.flags
)
return json.loads(output)
except libvirt.libvirtError as e:
if e.get_error_code() == libvirt.VIR_ERR_AGENT_UNRESPONSIVE:
raise GuestAgentUnavailableError(e) from e
raise GuestAgentError(e) from e
def is_available(self) -> bool:
"""
Execute guest-ping.
:return: True or False if guest agent is unreachable.
:rtype: bool
"""
try:
if self.execute({'execute': 'guest-ping', 'arguments': {}}):
return True
except GuestAgentError:
return False
def get_supported_commands(self) -> set[str]:
"""Return set of supported guest agent commands."""
output = self.execute({'execute': 'guest-info', 'arguments': {}})
return {
cmd['name']
for cmd in output['return']['supported_commands']
if cmd['enabled'] is True
}
def raise_for_commands(self, commands: list[str]) -> None:
"""
Raise exception if QEMU GA command is not available.
:param commands: List of required commands
:raise: GuestAgentCommandNotSupportedError
"""
supported = self.get_supported_commands()
for command in commands:
if command not in supported:
raise GuestAgentCommandNotSupportedError(command)
def guest_exec( # noqa: PLR0913
self,
path: str,
args: list[str] | None = None,
env: list[str] | None = None,
stdin: str | None = None,
*,
capture_output: bool = False,
decode_output: bool = False,
poll: bool = False,
) -> GuestExecOutput:
"""
Execute qemu-exec command and return output.
:param path: Path ot executable on guest.
:param arg: List of arguments to pass to executable.
:param env: List of environment variables to pass to executable.
For example: ``['LANG=C', 'TERM=xterm']``
:param stdin: Data to pass to executable STDIN.
:param capture_output: Capture command output.
:param decode_output: Use base64_decode() to decode command output.
Affects only if `capture_output` is True.
:param poll: Poll command output. Uses `self.timeout` and
POLL_INTERVAL constant.
:return: Command output
:rtype: GuestExecOutput
"""
self.raise_for_commands(['guest-exec', 'guest-exec-status'])
command = {
'execute': 'guest-exec',
'arguments': {
'path': path,
**({'arg': args} if args else {}),
**({'env': env} if env else {}),
**(
{
'input-data': standard_b64encode(
stdin.encode('utf-8')
).decode('utf-8')
}
if stdin
else {}
),
'capture-output': capture_output,
},
}
output = self.execute(command)
self.last_pid = pid = output['return']['pid']
command_status = self.guest_exec_status(pid, poll=poll)['return']
exited = command_status['exited']
exitcode = command_status['exitcode']
stdout = command_status.get('out-data', None)
stderr = command_status.get('err-data', None)
if decode_output:
stdout = b64decode(stdout or '').decode('utf-8')
stderr = b64decode(stderr or '').decode('utf-8')
return GuestExecOutput(exited, exitcode, stdout, stderr)
def guest_exec_status(
self, pid: int, *, poll: bool = False, poll_interval: float = 0.3
) -> dict:
"""
Execute guest-exec-status and return output.
:param pid: PID in guest.
:param poll: If True poll command status.
:param poll_interval: Time between attempts to obtain command status.
:return: Command output
:rtype: dict
"""
self.raise_for_commands(['guest-exec-status'])
command = {
'execute': 'guest-exec-status',
'arguments': {'pid': pid},
}
if not poll:
return self.execute(command)
start_time = time()
while True:
command_status = self.execute(command)
if command_status['return']['exited']:
break
sleep(poll_interval)
now = time()
if now - start_time > self.timeout:
raise GuestAgentTimeoutExceededError(self.timeout)
log.debug(
'Polling command pid=%s finished, time taken: %s seconds',
pid,
int(time() - start_time),
)
return command_status

View File

@ -0,0 +1,675 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage compute instances."""
__all__ = ['Instance', 'InstanceConfig', 'InstanceInfo']
import logging
from typing import NamedTuple
import libvirt
from lxml import etree
from lxml.builder import E
from compute.common import DeviceConfig, EntityConfig
from compute.exceptions import (
GuestAgentCommandNotSupportedError,
InstanceError,
)
from compute.storage import DiskConfig
from compute.utils import units
from .guest_agent import GuestAgent
from .schemas import (
CPUEmulationMode,
CPUSchema,
InstanceSchema,
NetworkInterfaceSchema,
)
log = logging.getLogger(__name__)
class InstanceConfig(EntityConfig):
"""Compute instance XML config builder."""
def __init__(self, schema: InstanceSchema):
"""
Initialise InstanceConfig.
:param schema: InstanceSchema object
"""
self.name = schema.name
self.title = schema.title
self.description = schema.description
self.memory = schema.memory
self.max_memory = schema.max_memory
self.vcpus = schema.vcpus
self.max_vcpus = schema.max_vcpus
self.cpu = schema.cpu
self.machine = schema.machine
self.emulator = schema.emulator
self.arch = schema.arch
self.boot = schema.boot
self.network_interfaces = schema.network_interfaces
def _gen_cpu_xml(self, cpu: CPUSchema) -> etree.Element:
options = {
'mode': cpu.emulation_mode,
'match': 'exact',
'check': 'partial',
}
if cpu.emulation_mode == CPUEmulationMode.HOST_PASSTHROUGH:
options['check'] = 'none'
options['migratable'] = 'on'
xml = E.cpu(**options)
if cpu.model:
xml.append(E.model(cpu.model, fallback='forbid'))
if cpu.vendor:
xml.append(E.vendor(cpu.vendor))
if cpu.topology:
xml.append(
E.topology(
sockets=str(cpu.topology.sockets),
dies=str(cpu.topology.dies),
cores=str(cpu.topology.cores),
threads=str(cpu.topology.threads),
)
)
if cpu.features:
for feature in cpu.features.require:
xml.append(E.feature(policy='require', name=feature))
for feature in cpu.features.disable:
xml.append(E.feature(policy='disable', name=feature))
return xml
def _gen_vcpus_xml(self, vcpus: int, max_vcpus: int) -> etree.Element:
xml = E.vcpus()
xml.append(E.vcpu(id='0', enabled='yes', hotpluggable='no', order='1'))
for i in range(max_vcpus - 1):
enabled = 'yes' if (i + 2) <= vcpus else 'no'
xml.append(
E.vcpu(
id=str(i + 1),
enabled=enabled,
hotpluggable='yes',
order=str(i + 2),
)
)
return xml
def _gen_network_interface_xml(
self, interface: NetworkInterfaceSchema
) -> etree.Element:
return E.interface(
E.source(network=interface.source),
E.mac(address=interface.mac),
type='network',
)
def to_xml(self) -> str:
"""Return XML config for libvirt."""
xml = E.domain(type='kvm')
xml.append(E.name(self.name))
if self.title:
xml.append(E.title(self.title))
if self.description:
xml.append(E.description(self.description))
xml.append(E.metadata())
xml.append(E.memory(str(self.max_memory * 1024), unit='KiB'))
xml.append(E.currentMemory(str(self.memory * 1024), unit='KiB'))
xml.append(
E.vcpu(
str(self.max_vcpus),
placement='static',
current=str(self.vcpus),
)
)
xml.append(self._gen_cpu_xml(self.cpu))
os = E.os(E.type('hvm', machine=self.machine, arch=self.arch))
for dev in self.boot.order:
os.append(E.boot(dev=dev))
xml.append(os)
xml.append(E.features(E.acpi(), E.apic()))
xml.append(E.on_poweroff('destroy'))
xml.append(E.on_reboot('restart'))
xml.append(E.on_crash('restart'))
xml.append(
E.pm(
E('suspend-to-mem', enabled='no'),
E('suspend-to-disk', enabled='no'),
)
)
devices = E.devices()
devices.append(E.emulator(str(self.emulator)))
for interface in self.network_interfaces:
devices.append(self._gen_network_interface_xml(interface))
devices.append(E.graphics(type='vnc', port='-1', autoport='yes'))
devices.append(E.input(type='tablet', bus='usb'))
devices.append(
E.channel(
E.source(mode='bind'),
E.target(type='virtio', name='org.qemu.guest_agent.0'),
E.address(
type='virtio-serial', controller='0', bus='0', port='1'
),
type='unix',
)
)
devices.append(
E.console(E.target(type='serial', port='0'), type='pty')
)
devices.append(
E.video(
E.model(type='vga', vram='16384', heads='1', primary='yes')
)
)
xml.append(devices)
return etree.tostring(xml, encoding='unicode', pretty_print=True)
class InstanceInfo(NamedTuple):
"""
Store compute instance info.
Reference:
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainInfo
"""
state: str
max_memory: int
memory: int
nproc: int
cputime: int
class Instance:
"""Manage compute instances."""
def __init__(self, domain: libvirt.virDomain):
"""
Initialise Instance.
:ivar libvirt.virDomain domain: domain object
:ivar libvirt.virConnect connection: connection object
:ivar str name: domain name
:ivar GuestAgent guest_agent: :class:`GuestAgent` object
:param domain: libvirt domain object
"""
self.domain = domain
self.connection = domain.connect()
self.name = domain.name()
self.guest_agent = GuestAgent(domain)
def _expand_instance_state(self, state: int) -> str:
states = {
libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
libvirt.VIR_DOMAIN_RUNNING: 'running',
libvirt.VIR_DOMAIN_BLOCKED: 'blocked',
libvirt.VIR_DOMAIN_PAUSED: 'paused',
libvirt.VIR_DOMAIN_SHUTDOWN: 'shutdown',
libvirt.VIR_DOMAIN_SHUTOFF: 'shutoff',
libvirt.VIR_DOMAIN_CRASHED: 'crashed',
libvirt.VIR_DOMAIN_PMSUSPENDED: 'pmsuspended',
}
return states[state]
def get_info(self) -> InstanceInfo:
"""Return instance info."""
info = self.domain.info()
return InstanceInfo(
state=self._expand_instance_state(info[0]),
max_memory=info[1],
memory=info[2],
nproc=info[3],
cputime=info[4],
)
def get_status(self) -> str:
"""
Return instance state: 'running', 'shutoff', etc.
Reference:
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
"""
try:
state, _ = self.domain.state()
except libvirt.libvirtError as e:
raise InstanceError(
'Cannot fetch status of ' f'instance={self.name}: {e}'
) from e
return self._expand_instance_state(state)
def is_running(self) -> bool:
"""Return True if instance is running, else return False."""
if self.domain.isActive() != 1:
# 0 - is inactive, -1 - is error
return False
return True
def is_autostart(self) -> bool:
"""Return True if instance autostart is enabled, else return False."""
try:
return bool(self.domain.autostart())
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot get autostart status for '
f'instance={self.name}: {e}'
) from e
def get_max_memory(self) -> int:
"""Maximum memory value for domain in KiB."""
return self.domain.maxMemory()
def get_max_vcpus(self) -> int:
"""Maximum vCPUs number for domain."""
return self.domain.maxVcpus()
def start(self) -> None:
"""Start defined instance."""
log.info('Starting instnce=%s', self.name)
if self.is_running():
log.warning(
'Already started, nothing to do instance=%s', self.name
)
return
try:
self.domain.create()
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot start instance={self.name}: {e}'
) from e
def shutdown(self, method: str | None = None) -> None:
"""
Shutdown instance.
Shutdown methods:
SOFT
Use guest agent to shutdown. If guest agent is unavailable
NORMAL method will be used.
NORMAL
Use method choosen by hypervisor to shutdown. Usually send ACPI
signal to guest OS. OS may ignore ACPI e.g. if guest is hanged.
HARD
Shutdown instance without any guest OS shutdown. This is simular
to unplugging machine from power. Internally send SIGTERM to
instance process and destroy it gracefully.
UNSAFE
Force shutdown. Internally send SIGKILL to instance process.
There is high data corruption risk!
If method is None NORMAL method will used.
:param method: Method used to shutdown instance
"""
methods = {
'SOFT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
'HARD': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL,
'UNSAFE': libvirt.VIR_DOMAIN_DESTROY_DEFAULT,
}
if method is None:
method = 'NORMAL'
if not isinstance(method, str):
raise TypeError(
f"Shutdown method must be a 'str', not {type(method)}"
)
method = method.upper()
if method not in methods:
raise ValueError(f"Unsupported shutdown method: '{method}'")
try:
if method in ['SOFT', 'NORMAL']:
self.domain.shutdownFlags(flags=methods[method])
elif method in ['HARD', 'UNSAFE']:
self.domain.destroyFlags(flags=methods[method])
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot shutdown instance={self.name} ' f'{method=}: {e}'
) from e
def reboot(self) -> None:
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
try:
self.domain.reboot()
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot reboot instance={self.name}: {e}'
) from e
def reset(self) -> None:
"""
Reset instance.
Copypaste from libvirt doc:
Reset a domain immediately without any guest OS shutdown.
Reset emulates the power reset button on a machine, where all
hardware sees the RST line set and reinitializes internal state.
Note that there is a risk of data loss caused by reset without any
guest OS shutdown.
"""
try:
self.domain.reset()
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot reset instance={self.name}: {e}'
) from e
def power_reset(self) -> None:
"""
Shutdown instance and start.
By analogy with real hardware, this is a normal server shutdown,
and then turning off from the power supply and turning it on again.
This method is applicable in cases where there has been a
configuration change in libvirt and you need to restart the
instance to apply the new configuration.
"""
self.shutdown(method='NORMAL')
self.start()
def set_autostart(self, *, enabled: bool) -> None:
"""
Set autostart flag for instance.
:param enabled: Bool argument to set or unset autostart flag.
"""
autostart = 1 if enabled else 0
try:
self.domain.setAutostart(autostart)
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot set autostart flag for instance={self.name} '
f'{autostart=}: {e}'
) from e
def set_vcpus(self, nvcpus: int, *, live: bool = False) -> None:
"""
Set vCPU number.
If `live` is True and instance is not currently running vCPUs
will set in config and will applied when instance boot.
NB: Note that if this call is executed before the guest has
finished booting, the guest may fail to process the change.
:param nvcpus: Number of vCPUs
:param live: Affect a running instance
"""
if nvcpus <= 0:
raise InstanceError('Cannot set zero vCPUs')
if nvcpus > self.get_max_vcpus():
raise InstanceError('vCPUs count is greather than max_vcpus')
if nvcpus == self.get_info().nproc:
log.warning(
'Instance instance=%s already have %s vCPUs, nothing to do',
self.name,
nvcpus,
)
return
try:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
self.domain.setVcpusFlags(nvcpus, flags=flags)
if live is True:
if not self.is_running():
log.warning(
'Instance is not running, changes applied in '
'instance config.'
)
return
flags = libvirt.VIR_DOMAIN_AFFECT_LIVE
self.domain.setVcpusFlags(nvcpus, flags=flags)
if self.guest_agent.is_available():
try:
self.guest_agent.raise_for_commands(
['guest-set-vcpus']
)
flags = libvirt.VIR_DOMAIN_VCPU_GUEST
self.domain.setVcpusFlags(nvcpus, flags=flags)
except GuestAgentCommandNotSupportedError:
log.warning(
'Cannot set vCPUs in guest via agent, you may '
'need to apply changes in guest manually.'
)
else:
log.warning(
'Cannot set vCPUs in guest OS on instance=%s. '
'You may need to apply CPUs in guest manually.',
self.name,
)
except libvirt.libvirtError as e:
raise InstanceError(
f'Cannot set vCPUs for instance={self.name}: {e}'
) from e
def set_memory(self, memory: int, *, live: bool = False) -> None:
"""
Set memory.
If `live` is True and instance is not currently running set memory
in config and will applied when instance boot.
:param memory: Memory value in mebibytes
:param live: Affect a running instance
"""
if memory <= 0:
raise InstanceError('Cannot set zero memory')
if (memory * 1024) > self.get_max_memory():
raise InstanceError('Memory is greather than max_memory')
if (memory * 1024) == self.get_info().memory:
log.warning(
"Instance '%s' already have %s memory, nothing to do",
self.name,
memory,
)
return
if live and self.is_running():
flags = (
libvirt.VIR_DOMAIN_AFFECT_LIVE
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
)
else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
try:
self.domain.setMemoryFlags(memory * 1024, flags=flags)
except libvirt.libvirtError as e:
msg = f'Cannot set memory for instance={self.name} {memory=}: {e}'
raise InstanceError(msg) from e
def _get_disk_by_target(self, target: str) -> etree.Element:
xml = etree.fromstring(self.dump_xml()) # noqa: S320
child = xml.xpath(f'/domain/devices/disk/target[@dev="{target}"]')
return child[0].getparent() if child else None
def attach_device(
self, device: DeviceConfig, *, live: bool = False
) -> None:
"""
Attach device to compute instance.
:param device: Object with device description e.g. DiskConfig
:param live: Affect a running instance
"""
if live and self.is_running():
flags = (
libvirt.VIR_DOMAIN_AFFECT_LIVE
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
)
else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
if isinstance(device, DiskConfig): # noqa: SIM102
if self._get_disk_by_target(device.target):
log.warning(
"Volume with target '%s' is already attached",
device.target,
)
return
self.domain.attachDeviceFlags(device.to_xml(), flags=flags)
def detach_device(
self, device: DeviceConfig, *, live: bool = False
) -> None:
"""
Dettach device from compute instance.
:param device: Object with device description e.g. DiskConfig
:param live: Affect a running instance
"""
if live and self.is_running():
flags = (
libvirt.VIR_DOMAIN_AFFECT_LIVE
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
)
else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
if isinstance(device, DiskConfig): # noqa: SIM102
if self._get_disk_by_target(device.target) is None:
log.warning(
"Volume with target '%s' is already detached",
device.target,
)
return
self.domain.detachDeviceFlags(device.to_xml(), flags=flags)
def detach_disk(self, name: str) -> None:
"""
Detach disk device by target name.
There is no ``attach_disk()`` method. Use :func:`attach_device`
with :class:`DiskConfig` as argument.
:param name: Disk name e.g. 'vda', 'sda', etc. This name may
not match the name of the disk inside the guest OS.
"""
xml = self._get_disk_by_target(name)
if xml is None:
log.warning(
"Volume with target '%s' is already detached",
name,
)
return
disk_params = {
'disk_type': xml.get('type'),
'source': xml.find('source').get('file'),
'target': xml.find('target').get('dev'),
'readonly': False if xml.find('readonly') is None else True, # noqa: SIM211
}
for param in disk_params:
if disk_params[param] is None:
msg = (
f"Cannot detach volume with target '{name}': "
f"parameter '{param}' is not defined in libvirt XML "
'config on host.'
)
raise InstanceError(msg)
self.detach_device(DiskConfig(**disk_params), live=True)
def resize_disk(
self, name: str, capacity: int, unit: units.DataUnit
) -> None:
"""
Resize attached block device.
:param name: Disk device name e.g. `vda`, `sda`, etc.
:param capacity: New capacity.
:param unit: Capacity unit.
"""
self.domain.blockResize(
name,
units.to_bytes(capacity, unit=unit),
flags=libvirt.VIR_DOMAIN_BLOCK_RESIZE_BYTES,
)
def get_disks(self) -> list[DiskConfig]:
"""Return list of attached disks."""
raise NotImplementedError
def pause(self) -> None:
"""Pause instance."""
if not self.is_running():
raise InstanceError('Cannot pause inactive instance')
self.domain.suspend()
def resume(self) -> None:
"""Resume paused instance."""
self.domain.resume()
def get_ssh_keys(self, user: str) -> list[str]:
"""
Return list of SSH keys on guest for specific user.
:param user: Username.
"""
raise NotImplementedError
def set_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
"""
Add SSH keys to guest for specific user.
:param user: Username.
:param ssh_keys: List of public SSH keys.
"""
raise NotImplementedError
def delete_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
"""
Remove SSH keys from guest for specific user.
:param user: Username.
:param ssh_keys: List of public SSH keys.
"""
raise NotImplementedError
def set_user_password(
self, user: str, password: str, *, encrypted: bool = False
) -> None:
"""
Set new user password in guest OS.
This action performs by guest agent inside the guest.
:param user: Username.
:param password: Password.
:param encrypted: Set it to True if password is already encrypted.
Right encryption method depends on guest OS.
"""
if not self.guest_agent.is_available():
raise InstanceError(
'Cannot change password: guest agent is unavailable'
)
self.guest_agent.raise_for_commands(['guest-set-user-password'])
flags = libvirt.VIR_DOMAIN_PASSWORD_ENCRYPTED if encrypted else 0
self.domain.setUserPassword(user, password, flags=flags)
def dump_xml(self, *, inactive: bool = False) -> str:
"""Return instance XML description."""
flags = libvirt.VIR_DOMAIN_XML_INACTIVE if inactive else 0
return self.domain.XMLDesc(flags)
def delete(self) -> None:
"""Undefine instance."""
# TODO @ge: delete local disks
self.shutdown(method='HARD')
self.domain.undefine()

View File

@ -0,0 +1,165 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Compute instance related objects schemas."""
import re
from enum import StrEnum
from pathlib import Path
from pydantic import BaseModel, Extra, validator
from compute.utils.units import DataUnit
class EntityModel(BaseModel):
"""Basic entity model."""
class Config:
"""Do not allow extra fields."""
extra = Extra.forbid
class CPUEmulationMode(StrEnum):
"""CPU emulation mode enumerated."""
HOST_PASSTHROUGH = 'host-passthrough'
HOST_MODEL = 'host-model'
CUSTOM = 'custom'
MAXIMUM = 'maximum'
class CPUTopologySchema(EntityModel):
"""CPU topology model."""
sockets: int
cores: int
threads: int
dies: int = 1
class CPUFeaturesSchema(EntityModel):
"""CPU features model."""
require: list[str]
disable: list[str]
class CPUSchema(EntityModel):
"""CPU model."""
emulation_mode: CPUEmulationMode
model: str | None
vendor: str | None
topology: CPUTopologySchema | None
features: CPUFeaturesSchema | None
class VolumeType(StrEnum):
"""Storage volume types enumeration."""
FILE = 'file'
class VolumeCapacitySchema(EntityModel):
"""Storage volume capacity field model."""
value: int
unit: DataUnit
class VolumeSchema(EntityModel):
"""Storage volume model."""
type: VolumeType # noqa: A003
target: str
capacity: VolumeCapacitySchema
source: str | None = None
is_readonly: bool = False
is_system: bool = False
class NetworkInterfaceSchema(EntityModel):
"""Network inerface model."""
source: str
mac: str
class BootOptionsSchema(EntityModel):
"""Instance boot settings."""
order: tuple
class InstanceSchema(EntityModel):
"""Compute instance model."""
name: str
title: str | None
description: str | None
memory: int
max_memory: int
vcpus: int
max_vcpus: int
cpu: CPUSchema
machine: str
emulator: Path
arch: str
boot: BootOptionsSchema
volumes: list[VolumeSchema]
network_interfaces: list[NetworkInterfaceSchema]
image: str | None = None
@validator('name')
def _check_name(cls, value: str) -> str: # noqa: N805
if not re.match(r'^[a-z0-9_]+$', value):
msg = (
'Name can contain only lowercase letters, numbers '
'and underscore.'
)
raise ValueError(msg)
return value
@validator('cpu')
def _check_topology(cls, cpu: int, values: dict) -> CPUSchema: # noqa: N805
topo = cpu.topology
max_vcpus = values['max_vcpus']
if topo and topo.sockets * topo.cores * topo.threads != max_vcpus:
msg = f'CPU topology does not match with {max_vcpus=}'
raise ValueError(msg)
return cpu
@validator('volumes')
def _check_volumes(cls, volumes: list) -> list: # noqa: N805
if len([v for v in volumes if v.is_system is True]) != 1:
msg = 'volumes list must contain one system volume'
raise ValueError(msg)
vol_with_source = 0
for vol in volumes:
if vol.is_system is True and vol.is_readonly is True:
msg = 'volume marked as system cannot be readonly'
raise ValueError(msg)
if vol.source is not None:
vol_with_source += 1
return volumes
@validator('network_interfaces')
def _check_network_interfaces(cls, value: list) -> list: # noqa: N805
if not value:
msg = 'Network interfaces list must contain at least one element'
raise ValueError(msg)
return value

View File

@ -0,0 +1,286 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Hypervisor session manager."""
import logging
import os
from contextlib import AbstractContextManager
from types import TracebackType
from typing import Any, NamedTuple
from uuid import uuid4
import libvirt
from lxml import etree
from .exceptions import (
InstanceNotFoundError,
SessionError,
StoragePoolNotFoundError,
)
from .instance import Instance, InstanceConfig, InstanceSchema
from .storage import DiskConfig, StoragePool, VolumeConfig
from .utils import units
log = logging.getLogger(__name__)
class Capabilities(NamedTuple):
"""Store domain capabilities info."""
arch: str
virt_type: str
emulator: str
machine: str
max_vcpus: int
cpu_vendor: str
cpu_model: str
cpu_features: dict
usable_cpus: list[dict]
class NodeInfo(NamedTuple):
"""
Store compute node info.
See https://libvirt.org/html/libvirt-libvirt-host.html#virNodeInfo
NOTE: memory unit in libvirt docs is wrong! Actual unit is MiB.
"""
arch: str
memory: int
cpus: int
mhz: int
nodes: int
sockets: int
cores: int
threads: int
class Session(AbstractContextManager):
"""
Hypervisor session context manager.
:cvar IMAGES_POOL: images storage pool name taken from env
:cvar VOLUMES_POOL: volumes storage pool name taken from env
"""
IMAGES_POOL = os.getenv('CMP_IMAGES_POOL')
VOLUMES_POOL = os.getenv('CMP_VOLUMES_POOL')
def __init__(self, uri: str | None = None):
"""
Initialise session with hypervisor.
:ivar str uri: libvirt connection URI.
:ivar libvirt.virConnect connection: libvirt connection object.
:param uri: libvirt connection URI.
"""
self.uri = uri or 'qemu:///system'
self.connection = libvirt.open(self.uri)
def __enter__(self):
"""Return Session object."""
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
exc_traceback: TracebackType | None,
):
"""Close the connection when leaving the context."""
self.close()
def close(self) -> None:
"""Close connection to libvirt daemon."""
self.connection.close()
def get_node_info(self) -> NodeInfo:
"""Return information about compute node."""
info = self.connection.getInfo()
return NodeInfo(
arch=info[0],
memory=info[1],
cpus=info[2],
mhz=info[3],
nodes=info[4],
sockets=info[5],
cores=info[6],
threads=info[7],
)
def _cap_get_usable_cpus(self, xml: etree.Element) -> list[dict]:
x = xml.xpath('/domainCapabilities/cpu/mode[@name="custom"]')[0]
cpus = []
for cpu in x.findall('model'):
if cpu.get('usable') == 'yes':
cpus.append( # noqa: PERF401
{
'vendor': cpu.get('vendor'),
'model': cpu.text,
}
)
return cpus
def _cap_get_cpu_features(self, xml: etree.Element) -> dict:
x = xml.xpath('/domainCapabilities/cpu/mode[@name="host-model"]')[0]
require = []
disable = []
for feature in x.findall('feature'):
policy = feature.get('policy')
name = feature.get('name')
if policy == 'require':
require.append(name)
if policy == 'disable':
disable.append(name)
return {'require': require, 'disable': disable}
def get_capabilities(self) -> Capabilities:
"""Return capabilities e.g. arch, virt, emulator, etc."""
prefix = '/domainCapabilities'
hprefix = f'{prefix}/cpu/mode[@name="host-model"]'
caps = etree.fromstring(self.connection.getDomainCapabilities()) # noqa: S320
return Capabilities(
arch=caps.xpath(f'{prefix}/arch/text()')[0],
virt_type=caps.xpath(f'{prefix}/domain/text()')[0],
emulator=caps.xpath(f'{prefix}/path/text()')[0],
machine=caps.xpath(f'{prefix}/machine/text()')[0],
max_vcpus=int(caps.xpath(f'{prefix}/vcpu/@max')[0]),
cpu_vendor=caps.xpath(f'{hprefix}/vendor/text()')[0],
cpu_model=caps.xpath(f'{hprefix}/model/text()')[0],
cpu_features=self._cap_get_cpu_features(caps),
usable_cpus=self._cap_get_cpus(caps),
)
def create_instance(self, **kwargs: Any) -> Instance:
"""
Create and return new compute instance.
:param name: Instance name.
:type name: str
:param title: Instance title for humans.
:type title: str
:param description: Some information about instance.
:type description: str
:param memory: Memory in MiB.
:type memory: int
:param max_memory: Maximum memory in MiB.
:type max_memory: int
:param vcpus: Number of vCPUs.
:type vcpus: int
:param max_vcpus: Maximum vCPUs.
:type max_vcpus: int
:param cpu: CPU configuration. See :class:`CPUSchema` for info.
:type cpu: dict
:param machine: QEMU emulated machine.
:type machine: str
:param emulator: Path to emulator.
:type emulator: str
:param arch: CPU architecture to virtualization.
:type arch: str
:param boot: Boot settings. See :class:`BootOptionsSchema`.
:type boot: dict
:param image: Source disk image name for system disk.
:type image: str
:param volumes: List of storage volume configs. For more info
see :class:`VolumeSchema`.
:type volumes: list[dict]
:param network_interfaces: List of virtual network interfaces
configs. See :class:`NetworkInterfaceSchema` for more info.
:type network_interfaces: list[dict]
"""
data = InstanceSchema(**kwargs)
config = InstanceConfig(data)
log.info('Define XML...')
log.info(config.to_xml())
self.connection.defineXML(config.to_xml())
log.info('Getting instance...')
instance = self.get_instance(config.name)
log.info('Creating volumes...')
for volume in data.volumes:
log.info('Creating volume=%s', volume)
capacity = units.to_bytes(
volume.capacity.value, volume.capacity.unit
)
log.info('Connecting to images pool...')
images_pool = self.get_storage_pool(self.IMAGES_POOL)
log.info('Connecting to volumes pool...')
volumes_pool = self.get_storage_pool(self.VOLUMES_POOL)
log.info('Building volume configuration...')
if not volume.source:
vol_name = f'{uuid4()}.qcow2'
else:
vol_name = volume.source
vol_conf = VolumeConfig(
name=vol_name,
path=str(volumes_pool.path.joinpath(vol_name)),
capacity=capacity,
)
log.info('Volume configuration is:\n %s', vol_conf.to_xml())
if volume.is_system is True and data.image:
log.info(
"Volume is marked as 'system', start cloning image..."
)
log.info('Get image %s', data.image)
image = images_pool.get_volume(data.image)
log.info('Cloning image into volumes pool...')
vol = volumes_pool.clone_volume(image, vol_conf)
log.info(
'Resize cloned volume to specified size: %s',
capacity,
)
vol.resize(capacity, unit=units.DataUnit.BYTES)
else:
log.info('Create volume...')
volumes_pool.create_volume(vol_conf)
log.info('Attaching volume to instance...')
instance.attach_device(
DiskConfig(
disk_type=volume.type,
source=vol_conf.path,
target=volume.target,
readonly=volume.is_readonly,
)
)
return instance
def get_instance(self, name: str) -> Instance:
"""Get compute instance by name."""
try:
return Instance(self.connection.lookupByName(name))
except libvirt.libvirtError as e:
if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
raise InstanceNotFoundError(name) from e
raise SessionError(e) from e
def list_instances(self) -> list[Instance]:
"""List all instances."""
return [Instance(dom) for dom in self.connection.listAllDomains()]
def get_storage_pool(self, name: str) -> StoragePool:
"""Get storage pool by name."""
try:
return StoragePool(self.connection.storagePoolLookupByName(name))
except libvirt.libvirtError as e:
if e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_POOL:
raise StoragePoolNotFoundError(name) from e
raise SessionError(e) from e
def list_storage_pools(self) -> list[StoragePool]:
"""List all strage pools."""
return [StoragePool(p) for p in self.connection.listStoragePools()]

View File

@ -0,0 +1,17 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from .pool import StoragePool
from .volume import DiskConfig, Volume, VolumeConfig

View File

@ -0,0 +1,124 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage storage pools."""
import logging
from pathlib import Path
from typing import NamedTuple
import libvirt
from lxml import etree
from compute.exceptions import StoragePoolError, VolumeNotFoundError
from .volume import Volume, VolumeConfig
log = logging.getLogger(__name__)
class StoragePoolUsageInfo(NamedTuple):
"""Storage pool usage info."""
capacity: int
allocation: int
available: int
class StoragePool:
"""Storage pool manipulating class."""
def __init__(self, pool: libvirt.virStoragePool):
"""Initislise StoragePool."""
self.pool = pool
self.name = pool.name()
self.path = self._get_path()
def _get_path(self) -> Path:
"""Return storage pool path."""
xml = etree.fromstring(self.pool.XMLDesc()) # noqa: S320
return Path(xml.xpath('/pool/target/path/text()')[0])
def get_usage_info(self) -> StoragePoolUsageInfo:
"""Return info about storage pool usage."""
xml = etree.fromstring(self.pool.XMLDesc()) # noqa: S320
return StoragePoolUsageInfo(
capacity=int(xml.xpath('/pool/capacity/text()')[0]),
allocation=int(xml.xpath('/pool/allocation/text()')[0]),
available=int(xml.xpath('/pool/available/text()')[0]),
)
def dump_xml(self) -> str:
"""Return storage pool XML description as string."""
return self.pool.XMLDesc()
def refresh(self) -> None:
"""Refresh storage pool."""
# TODO @ge: handle libvirt asynchronous job related exceptions
self.pool.refresh()
def create_volume(self, vol_conf: VolumeConfig) -> Volume:
"""Create storage volume and return Volume instance."""
log.info(
'Create storage volume vol=%s in pool=%s', vol_conf.name, self.name
)
vol = self.pool.createXML(
vol_conf.to_xml(),
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
)
return Volume(self.pool, vol)
def clone_volume(self, src: Volume, dst: VolumeConfig) -> Volume:
"""
Make storage volume copy.
:param src: Input volume
:param dst: Output volume config
"""
log.info(
'Start volume cloning '
'src_pool=%s src_vol=%s dst_pool=%s dst_vol=%s',
src.pool_name,
src.name,
self.pool.name,
dst.name,
)
vol = self.pool.createXMLFrom(
dst.to_xml(), # new volume XML description
src.vol, # source volume virStorageVol object
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
)
if vol is None:
raise StoragePoolError
return Volume(self.pool, vol)
def get_volume(self, name: str) -> Volume | None:
"""Lookup and return Volume instance or None."""
log.info(
'Lookup for storage volume vol=%s in pool=%s', name, self.pool.name
)
try:
vol = self.pool.storageVolLookupByName(name)
return Volume(self.pool, vol)
except libvirt.libvirtError as e:
if e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL:
raise VolumeNotFoundError(name) from e
log.exception('unexpected error from libvirt')
raise StoragePoolError(e) from e
def list_volumes(self) -> list[Volume]:
"""Return list of volumes in storage pool."""
return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()]

View File

@ -0,0 +1,138 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Manage storage volumes."""
from dataclasses import dataclass
from pathlib import Path
from time import time
import libvirt
from lxml import etree
from lxml.builder import E
from compute.common import DeviceConfig, EntityConfig
from compute.utils import units
@dataclass
class VolumeConfig(EntityConfig):
"""
Storage volume XML config builder.
Generate XML config for creating a volume in a libvirt
storage pool.
"""
name: str
path: str
capacity: int
def to_xml(self) -> str:
"""Return XML config for libvirt."""
unixtime = str(int(time()))
xml = E.volume(type='file')
xml.append(E.name(self.name))
xml.append(E.key(self.path))
xml.append(E.source())
xml.append(E.capacity(str(self.capacity), unit='bytes'))
xml.append(E.allocation('0'))
xml.append(
E.target(
E.path(self.path),
E.format(type='qcow2'),
E.timestamps(
E.atime(unixtime), E.mtime(unixtime), E.ctime(unixtime)
),
E.compat('1.1'),
E.features(E.lazy_refcounts()),
)
)
return etree.tostring(xml, encoding='unicode', pretty_print=True)
@dataclass
class DiskConfig(DeviceConfig):
"""
Disk XML config builder.
Generate XML config for attaching or detaching storage volumes
to compute instances.
"""
disk_type: str
source: str | Path
target: str
readonly: bool = False
def to_xml(self) -> str:
"""Return XML config for libvirt."""
xml = E.disk(type=self.disk_type, device='disk')
xml.append(E.driver(name='qemu', type='qcow2', cache='writethrough'))
if self.disk_type == 'file':
xml.append(E.source(file=str(self.source)))
xml.append(E.target(dev=self.target, bus='virtio'))
if self.readonly:
xml.append(E.readonly())
return etree.tostring(xml, encoding='unicode', pretty_print=True)
class Volume:
"""Storage volume manipulating class."""
def __init__(
self, pool: libvirt.virStoragePool, vol: libvirt.virStorageVol
):
"""
Initialise Volume.
:param pool: libvirt virStoragePool object
:param vol: libvirt virStorageVol object
"""
self.pool = pool
self.pool_name = pool.name()
self.vol = vol
self.name = vol.name()
self.path = Path(vol.path())
def dump_xml(self) -> str:
"""Return volume XML description as string."""
return self.vol.XMLDesc()
def clone(self, vol_conf: VolumeConfig) -> None:
"""
Make a copy of volume to the same storage pool.
:param vol_info VolumeInfo: New storage volume dataclass object
"""
self.pool.createXMLFrom(
vol_conf.to_xml(),
self.vol,
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
)
def resize(self, capacity: int, unit: units.DataUnit) -> None:
"""
Resize volume.
:param capacity int: Volume new capacity.
:param unit DataUnit: Data unit. Internally converts into bytes.
"""
# TODO @ge: Check actual volume size before resize
self.vol.resize(units.to_bytes(capacity, unit=unit))
def delete(self) -> None:
"""Delete volume from storage pool."""
self.vol.delete()

View File

@ -0,0 +1,56 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Configuration loader."""
import tomllib
from collections import UserDict
from pathlib import Path
from compute.exceptions import ConfigLoaderError
DEFAULT_CONFIGURATION = {}
DEFAULT_CONFIG_FILE = '/etc/computed/computed.toml'
class ConfigLoader(UserDict):
"""UserDict for storing configuration."""
def __init__(self, file: Path | None = None):
"""
Initialise ConfigLoader.
:param file: Path to configuration file. If `file` is None
use default path from DEFAULT_CONFIG_FILE constant.
"""
# TODO @ge: load deafult configuration
self.file = Path(file) if file else Path(DEFAULT_CONFIG_FILE)
super().__init__(self.load())
def load(self) -> dict:
"""Load confguration object from TOML file."""
try:
with Path(self.file).open('rb') as configfile:
return tomllib.load(configfile)
# TODO @ge: add config schema validation
except tomllib.TOMLDecodeError as tomlerr:
raise ConfigLoaderError(
f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
) from tomlerr
except (OSError, ValueError) as readerr:
raise ConfigLoaderError(
f'Cannot read config file: {self.file}: {readerr}'
) from readerr

View File

@ -0,0 +1,33 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Random identificators."""
# ruff: noqa: S311, C417
import random
def random_mac() -> str:
"""Retrun random MAC address."""
mac = [
0x00,
0x16,
0x3E,
random.randint(0x00, 0x7F),
random.randint(0x00, 0xFF),
random.randint(0x00, 0xFF),
]
return ':'.join(map(lambda x: '%02x' % x, mac))

View File

@ -0,0 +1,54 @@
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
"""Tools for data units convertion."""
from enum import StrEnum
class DataUnit(StrEnum):
"""Data units enumerated."""
BYTES = 'bytes'
KIB = 'KiB'
MIB = 'MiB'
GIB = 'GiB'
TIB = 'TiB'
class InvalidDataUnitError(ValueError):
"""Data unit is not valid."""
def __init__(self, msg: str):
"""Initialise InvalidDataUnitError."""
super().__init__(
f'{msg}, valid units are: {", ".join(list(DataUnit))}'
)
def to_bytes(value: int, unit: DataUnit = DataUnit.BYTES) -> int:
"""Convert value to bytes. See :class:`DataUnit`."""
try:
_ = DataUnit(unit)
except ValueError as e:
raise InvalidDataUnitError(e) from e
powers = {
DataUnit.BYTES: 0,
DataUnit.KIB: 1,
DataUnit.MIB: 2,
DataUnit.GIB: 3,
DataUnit.TIB: 4,
}
return value * pow(1024, powers[unit])

View File

@ -0,0 +1,5 @@
compute (0.1.0.dev1-1) UNRELEASED; urgency=medium
* This is the development build, see commits in upstream repo for info.
-- ge <ge@nixhacks.net> Wed, 22 Nov 2023 23:06:43 +0000

View File

@ -0,0 +1,5 @@
compute (0.1.0.dev1-1) UNRELEASED; urgency=medium
* This is the development build, see commits in upstream repo for info.
-- ge <ge@nixhacks.net> Wed, 22 Nov 2023 23:06:43 +0000

View File

@ -0,0 +1,5 @@
compute (0.1.0.dev1-1) UNRELEASED; urgency=medium
* This is the development build, see commits in upstream repo for info.
-- ge <ge@nixhacks.net> Wed, 22 Nov 2023 23:06:43 +0000

View File

@ -0,0 +1 @@
dh_sphinxdoc

View File

@ -0,0 +1,4 @@
sphinxdoc:Depends=libjs-sphinxdoc (>= 1.0), libjs-sphinxdoc (>= 2.4.3-5~), libjs-sphinxdoc (>= 5.0), libjs-sphinxdoc (>= 5.2)
sphinxdoc:Built-Using=alabaster (= 0.7.12-1), sphinx (= 5.3.0-4)
misc:Depends=
misc:Pre-Depends=

View File

@ -0,0 +1,11 @@
Package: compute-doc
Source: compute
Version: 0.1.0.dev1-1
Architecture: all
Maintainer: ge <ge@nixhacks.net>
Installed-Size: 376
Depends: libjs-sphinxdoc (>= 5.2)
Section: doc
Priority: optional
Homepage: https://git.lulzette.ru/hstack/compute
Description: Compute instances management library and tools (documentation)

View File

@ -0,0 +1,40 @@
6845278a102bd147f30f770ed1134ce5 usr/share/doc/compute-doc/changelog.Debian.gz
fb1a6c11d7a8fa5f238617c20b13b6a1 usr/share/doc/compute-doc/copyright
705113edf19bbf7f9d406fccd98ebef9 usr/share/doc/compute-doc/html/_sources/index.rst.txt
91934f7b742b8395043e25cfa73682af usr/share/doc/compute-doc/html/_sources/pyapi/exceptions.rst.txt
de8bc1c2c00774ddee5363aef80c0775 usr/share/doc/compute-doc/html/_sources/pyapi/index.rst.txt
2a0040e0a150de53ed929e963af635a8 usr/share/doc/compute-doc/html/_sources/pyapi/instance/guest_agent.rst.txt
dd6324cb85dc57ef37c4f8161aa2d233 usr/share/doc/compute-doc/html/_sources/pyapi/instance/index.rst.txt
c594567565cc48a247932409d9adcc4a usr/share/doc/compute-doc/html/_sources/pyapi/instance/instance.rst.txt
e6a69ab447e455dba6e7b865a3d872d2 usr/share/doc/compute-doc/html/_sources/pyapi/instance/schemas.rst.txt
ba27654c086857e64d58468b13bc31c4 usr/share/doc/compute-doc/html/_sources/pyapi/session.rst.txt
801ccc953fc57199b06ec122e10f784c usr/share/doc/compute-doc/html/_sources/pyapi/storage/index.rst.txt
324ae7c877f3cf7895b2a5d3af579345 usr/share/doc/compute-doc/html/_sources/pyapi/storage/pool.rst.txt
db91c0d83c2c80e9f9323a8943eeeff4 usr/share/doc/compute-doc/html/_sources/pyapi/storage/volume.rst.txt
572ed749dd8924c36f1afe9e8e14d4d3 usr/share/doc/compute-doc/html/_sources/pyapi/utils.rst.txt
4fc9d553e40384beedf38e21f205d2a7 usr/share/doc/compute-doc/html/_static/alabaster.css
23ffe661f835b08e157d492a86aae74d usr/share/doc/compute-doc/html/_static/basic.css
dad0c9b31e59069c83018ce87594ed65 usr/share/doc/compute-doc/html/_static/custom.css
5e103d51310d4e0c065325d795cc9def usr/share/doc/compute-doc/html/_static/documentation_options.js
ba0c95766a77a6c598a7ca542f1db738 usr/share/doc/compute-doc/html/_static/file.png
5b6b3233153feca50a94aa6c60873a5f usr/share/doc/compute-doc/html/_static/forkme_right_darkblue_121621.png
36b1a4b05451c7acde7ced60b2f6bc21 usr/share/doc/compute-doc/html/_static/minus.png
0d7849fd4d4148b7f78cab60a087633a usr/share/doc/compute-doc/html/_static/plus.png
4f81be1c1dd97a6ec76af15b8f926189 usr/share/doc/compute-doc/html/_static/pygments.css
fd297228a19ece7e38824d0704f3635d usr/share/doc/compute-doc/html/genindex.html
3e038e6169c721ebacf889ea4ac5c1bf usr/share/doc/compute-doc/html/index.html
b8e4906e5136e907ab0d7ae826720603 usr/share/doc/compute-doc/html/objects.inv
2658558520c0c9f209dd4c69516facfd usr/share/doc/compute-doc/html/py-modindex.html
4254a2ecc3e154f52646febebd0ef6e6 usr/share/doc/compute-doc/html/pyapi/exceptions.html
bf4609f321d2c60399574c3e52dd6a44 usr/share/doc/compute-doc/html/pyapi/index.html
730aab71986cb938e9aff03ba203c9a9 usr/share/doc/compute-doc/html/pyapi/instance/guest_agent.html
fad8eba8a9cb9b1befd8e0ecdf1bbe5f usr/share/doc/compute-doc/html/pyapi/instance/index.html
781272676f0b35c52f43b99f2ca86647 usr/share/doc/compute-doc/html/pyapi/instance/instance.html
ede88501ec628083bb1ad1cb86cdec9f usr/share/doc/compute-doc/html/pyapi/instance/schemas.html
4c8d372d298068aba7272d11feb2cc52 usr/share/doc/compute-doc/html/pyapi/session.html
000f86f6184a455843017772ff2fec9d usr/share/doc/compute-doc/html/pyapi/storage/index.html
a2b63c0194a1e55be8d7036b46851986 usr/share/doc/compute-doc/html/pyapi/storage/pool.html
8d4e9081b213585aad36b4daadc37e26 usr/share/doc/compute-doc/html/pyapi/storage/volume.html
307d7a44f4343b0f34ee758e4ab20d88 usr/share/doc/compute-doc/html/pyapi/utils.html
5999199d4710213969f7fb1b50647f4a usr/share/doc/compute-doc/html/search.html
148b182d3691ae88c629783c3623007d usr/share/doc/compute-doc/html/searchindex.js

View File

@ -0,0 +1,32 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: https://git.lulzette.ru/hstack/compute
Upstream-Name: compute
Files:
*
Copyright:
2023 ge <ge@nixhacks.net>
License: GPL-3.0+
Files:
debian/*
Copyright:
2023 ge <ge@nixhacks.net>
License: GPL-3.0+
License: GPL-3.0+
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
.
This package is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Comment:
On Debian systems, the complete text of the GNU General
Public License version 3 can be found in "/usr/share/common-licenses/GPL-3".

View File

@ -0,0 +1,16 @@
Compute
=======
Compute instances management library.
.. toctree::
:maxdepth: 1
pyapi/index
Indices and tables
------------------
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View File

@ -0,0 +1,5 @@
``exceptions``
==============
.. automodule:: compute.exceptions
:members:

View File

@ -0,0 +1,49 @@
Python API
==========
The API allows you to perform actions on instances programmatically. Below is
an example of changing parameters and launching the `myinstance` instance.
.. code-block:: python
import logging
from compute import Session
logging.basicConfig(level=logging.DEBUG)
with Session() as session:
instance = session.get_instance('myinstance')
instance.set_vcpus(4)
instance.start()
instance.set_autostart(enabled=True)
:class:`Session` context manager provides an abstraction over :class:`libvirt.virConnect`
and returns objects of other classes of the present library.
Entity representation
---------------------
Entities such as a compute-instance are represented as classes. These classes directly
call libvirt methods to perform operations on the hypervisor. An example class is
:class:`Volume`.
The configuration files of various libvirt objects in `compute` are described by special
dataclasses. The dataclass stores object parameters in its properties and can return an
XML config for libvirt using the ``to_xml()`` method. For example :class:`VolumeConfig`.
`Pydantic <https://docs.pydantic.dev/>`_ models are used to validate input data.
For example :class:`VolumeSchema`.
Modules documentation
---------------------
.. toctree::
:maxdepth: 4
session
instance/index
storage/index
utils
exceptions

View File

@ -0,0 +1,6 @@
``guest_agent``
===============
.. automodule:: compute.instance.guest_agent
:members:
:special-members: __init__

View File

@ -0,0 +1,10 @@
``instance``
============
.. toctree::
:maxdepth: 1
:caption: Contents:
instance
guest_agent
schemas

View File

@ -0,0 +1,6 @@
``instance``
============
.. automodule:: compute.instance.instance
:members:
:special-members: __init__

View File

@ -0,0 +1,5 @@
``schemas``
===========
.. automodule:: compute.instance.schemas
:members:

View File

@ -0,0 +1,6 @@
``session``
===========
.. automodule:: compute.session
:members:
:special-members: __init__

View File

@ -0,0 +1,9 @@
``storage``
============
.. toctree::
:maxdepth: 1
:caption: Contents:
pool
volume

View File

@ -0,0 +1,6 @@
``pool``
========
.. automodule:: compute.storage.pool
:members:
:special-members: __init__

View File

@ -0,0 +1,6 @@
``volume``
==========
.. automodule:: compute.storage.volume
:members:
:special-members: __init__

View File

@ -0,0 +1,14 @@
``utils``
=========
``utils.units``
---------------
.. automodule:: compute.utils.units
:members:
``utils.ids``
-------------
.. automodule:: compute.utils.ids
:members:

View File

@ -0,0 +1 @@
../../../../javascript/sphinxdoc/1.0/_sphinx_javascript_frameworks_compat.js

View File

@ -0,0 +1,701 @@
@import url("basic.css");
/* -- page layout ----------------------------------------------------------- */
body {
font-family: Georgia, serif;
font-size: 17px;
background-color: #fff;
color: #000;
margin: 0;
padding: 0;
}
div.document {
width: 940px;
margin: 30px auto 0 auto;
}
div.documentwrapper {
float: left;
width: 100%;
}
div.bodywrapper {
margin: 0 0 0 220px;
}
div.sphinxsidebar {
width: 220px;
font-size: 14px;
line-height: 1.5;
}
hr {
border: 1px solid #B1B4B6;
}
div.body {
background-color: #fff;
color: #3E4349;
padding: 0 30px 0 30px;
}
div.body > .section {
text-align: left;
}
div.footer {
width: 940px;
margin: 20px auto 30px auto;
font-size: 14px;
color: #888;
text-align: right;
}
div.footer a {
color: #888;
}
p.caption {
font-family: inherit;
font-size: inherit;
}
div.relations {
display: none;
}
div.sphinxsidebar a {
color: #444;
text-decoration: none;
border-bottom: 1px dotted #999;
}
div.sphinxsidebar a:hover {
border-bottom: 1px solid #999;
}
div.sphinxsidebarwrapper {
padding: 18px 10px;
}
div.sphinxsidebarwrapper p.logo {
padding: 0;
margin: -10px 0 0 0px;
text-align: center;
}
div.sphinxsidebarwrapper h1.logo {
margin-top: -10px;
text-align: center;
margin-bottom: 5px;
text-align: left;
}
div.sphinxsidebarwrapper h1.logo-name {
margin-top: 0px;
}
div.sphinxsidebarwrapper p.blurb {
margin-top: 0;
font-style: normal;
}
div.sphinxsidebar h3,
div.sphinxsidebar h4 {
font-family: Georgia, serif;
color: #444;
font-size: 24px;
font-weight: normal;
margin: 0 0 5px 0;
padding: 0;
}
div.sphinxsidebar h4 {
font-size: 20px;
}
div.sphinxsidebar h3 a {
color: #444;
}
div.sphinxsidebar p.logo a,
div.sphinxsidebar h3 a,
div.sphinxsidebar p.logo a:hover,
div.sphinxsidebar h3 a:hover {
border: none;
}
div.sphinxsidebar p {
color: #555;
margin: 10px 0;
}
div.sphinxsidebar ul {
margin: 10px 0;
padding: 0;
color: #000;
}
div.sphinxsidebar ul li.toctree-l1 > a {
font-size: 120%;
}
div.sphinxsidebar ul li.toctree-l2 > a {
font-size: 110%;
}
div.sphinxsidebar input {
border: 1px solid #CCC;
font-family: Georgia, serif;
font-size: 1em;
}
div.sphinxsidebar hr {
border: none;
height: 1px;
color: #AAA;
background: #AAA;
text-align: left;
margin-left: 0;
width: 50%;
}
div.sphinxsidebar .badge {
border-bottom: none;
}
div.sphinxsidebar .badge:hover {
border-bottom: none;
}
/* To address an issue with donation coming after search */
div.sphinxsidebar h3.donation {
margin-top: 10px;
}
/* -- body styles ----------------------------------------------------------- */
a {
color: #004B6B;
text-decoration: underline;
}
a:hover {
color: #6D4100;
text-decoration: underline;
}
div.body h1,
div.body h2,
div.body h3,
div.body h4,
div.body h5,
div.body h6 {
font-family: Georgia, serif;
font-weight: normal;
margin: 30px 0px 10px 0px;
padding: 0;
}
div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; }
div.body h2 { font-size: 180%; }
div.body h3 { font-size: 150%; }
div.body h4 { font-size: 130%; }
div.body h5 { font-size: 100%; }
div.body h6 { font-size: 100%; }
a.headerlink {
color: #DDD;
padding: 0 4px;
text-decoration: none;
}
a.headerlink:hover {
color: #444;
background: #EAEAEA;
}
div.body p, div.body dd, div.body li {
line-height: 1.4em;
}
div.admonition {
margin: 20px 0px;
padding: 10px 30px;
background-color: #EEE;
border: 1px solid #CCC;
}
div.admonition tt.xref, div.admonition code.xref, div.admonition a tt {
background-color: #FBFBFB;
border-bottom: 1px solid #fafafa;
}
div.admonition p.admonition-title {
font-family: Georgia, serif;
font-weight: normal;
font-size: 24px;
margin: 0 0 10px 0;
padding: 0;
line-height: 1;
}
div.admonition p.last {
margin-bottom: 0;
}
div.highlight {
background-color: #fff;
}
dt:target, .highlight {
background: #FAF3E8;
}
div.warning {
background-color: #FCC;
border: 1px solid #FAA;
}
div.danger {
background-color: #FCC;
border: 1px solid #FAA;
-moz-box-shadow: 2px 2px 4px #D52C2C;
-webkit-box-shadow: 2px 2px 4px #D52C2C;
box-shadow: 2px 2px 4px #D52C2C;
}
div.error {
background-color: #FCC;
border: 1px solid #FAA;
-moz-box-shadow: 2px 2px 4px #D52C2C;
-webkit-box-shadow: 2px 2px 4px #D52C2C;
box-shadow: 2px 2px 4px #D52C2C;
}
div.caution {
background-color: #FCC;
border: 1px solid #FAA;
}
div.attention {
background-color: #FCC;
border: 1px solid #FAA;
}
div.important {
background-color: #EEE;
border: 1px solid #CCC;
}
div.note {
background-color: #EEE;
border: 1px solid #CCC;
}
div.tip {
background-color: #EEE;
border: 1px solid #CCC;
}
div.hint {
background-color: #EEE;
border: 1px solid #CCC;
}
div.seealso {
background-color: #EEE;
border: 1px solid #CCC;
}
div.topic {
background-color: #EEE;
}
p.admonition-title {
display: inline;
}
p.admonition-title:after {
content: ":";
}
pre, tt, code {
font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
font-size: 0.9em;
}
.hll {
background-color: #FFC;
margin: 0 -12px;
padding: 0 12px;
display: block;
}
img.screenshot {
}
tt.descname, tt.descclassname, code.descname, code.descclassname {
font-size: 0.95em;
}
tt.descname, code.descname {
padding-right: 0.08em;
}
img.screenshot {
-moz-box-shadow: 2px 2px 4px #EEE;
-webkit-box-shadow: 2px 2px 4px #EEE;
box-shadow: 2px 2px 4px #EEE;
}
table.docutils {
border: 1px solid #888;
-moz-box-shadow: 2px 2px 4px #EEE;
-webkit-box-shadow: 2px 2px 4px #EEE;
box-shadow: 2px 2px 4px #EEE;
}
table.docutils td, table.docutils th {
border: 1px solid #888;
padding: 0.25em 0.7em;
}
table.field-list, table.footnote {
border: none;
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
}
table.footnote {
margin: 15px 0;
width: 100%;
border: 1px solid #EEE;
background: #FDFDFD;
font-size: 0.9em;
}
table.footnote + table.footnote {
margin-top: -15px;
border-top: none;
}
table.field-list th {
padding: 0 0.8em 0 0;
}
table.field-list td {
padding: 0;
}
table.field-list p {
margin-bottom: 0.8em;
}
/* Cloned from
* https://github.com/sphinx-doc/sphinx/commit/ef60dbfce09286b20b7385333d63a60321784e68
*/
.field-name {
-moz-hyphens: manual;
-ms-hyphens: manual;
-webkit-hyphens: manual;
hyphens: manual;
}
table.footnote td.label {
width: .1px;
padding: 0.3em 0 0.3em 0.5em;
}
table.footnote td {
padding: 0.3em 0.5em;
}
dl {
margin: 0;
padding: 0;
}
dl dd {
margin-left: 30px;
}
blockquote {
margin: 0 0 0 30px;
padding: 0;
}
ul, ol {
/* Matches the 30px from the narrow-screen "li > ul" selector below */
margin: 10px 0 10px 30px;
padding: 0;
}
pre {
background: #EEE;
padding: 7px 30px;
margin: 15px 0px;
line-height: 1.3em;
}
div.viewcode-block:target {
background: #ffd;
}
dl pre, blockquote pre, li pre {
margin-left: 0;
padding-left: 30px;
}
tt, code {
background-color: #ecf0f3;
color: #222;
/* padding: 1px 2px; */
}
tt.xref, code.xref, a tt {
background-color: #FBFBFB;
border-bottom: 1px solid #fff;
}
a.reference {
text-decoration: none;
border-bottom: 1px dotted #004B6B;
}
/* Don't put an underline on images */
a.image-reference, a.image-reference:hover {
border-bottom: none;
}
a.reference:hover {
border-bottom: 1px solid #6D4100;
}
a.footnote-reference {
text-decoration: none;
font-size: 0.7em;
vertical-align: top;
border-bottom: 1px dotted #004B6B;
}
a.footnote-reference:hover {
border-bottom: 1px solid #6D4100;
}
a:hover tt, a:hover code {
background: #EEE;
}
@media screen and (max-width: 870px) {
div.sphinxsidebar {
display: none;
}
div.document {
width: 100%;
}
div.documentwrapper {
margin-left: 0;
margin-top: 0;
margin-right: 0;
margin-bottom: 0;
}
div.bodywrapper {
margin-top: 0;
margin-right: 0;
margin-bottom: 0;
margin-left: 0;
}
ul {
margin-left: 0;
}
li > ul {
/* Matches the 30px from the "ul, ol" selector above */
margin-left: 30px;
}
.document {
width: auto;
}
.footer {
width: auto;
}
.bodywrapper {
margin: 0;
}
.footer {
width: auto;
}
.github {
display: none;
}
}
@media screen and (max-width: 875px) {
body {
margin: 0;
padding: 20px 30px;
}
div.documentwrapper {
float: none;
background: #fff;
}
div.sphinxsidebar {
display: block;
float: none;
width: 102.5%;
margin: 50px -30px -20px -30px;
padding: 10px 20px;
background: #333;
color: #FFF;
}
div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p,
div.sphinxsidebar h3 a {
color: #fff;
}
div.sphinxsidebar a {
color: #AAA;
}
div.sphinxsidebar p.logo {
display: none;
}
div.document {
width: 100%;
margin: 0;
}
div.footer {
display: none;
}
div.bodywrapper {
margin: 0;
}
div.body {
min-height: 0;
padding: 0;
}
.rtd_doc_footer {
display: none;
}
.document {
width: auto;
}
.footer {
width: auto;
}
.footer {
width: auto;
}
.github {
display: none;
}
}
/* misc. */
.revsys-inline {
display: none!important;
}
/* Make nested-list/multi-paragraph items look better in Releases changelog
* pages. Without this, docutils' magical list fuckery causes inconsistent
* formatting between different release sub-lists.
*/
div#changelog > div.section > ul > li > p:only-child {
margin-bottom: 0;
}
/* Hide fugly table cell borders in ..bibliography:: directive output */
table.docutils.citation, table.docutils.citation td, table.docutils.citation th {
border: none;
/* Below needed in some edge cases; if not applied, bottom shadows appear */
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
}
/* relbar */
.related {
line-height: 30px;
width: 100%;
font-size: 0.9rem;
}
.related.top {
border-bottom: 1px solid #EEE;
margin-bottom: 20px;
}
.related.bottom {
border-top: 1px solid #EEE;
}
.related ul {
padding: 0;
margin: 0;
list-style: none;
}
.related li {
display: inline;
}
nav#rellinks {
float: right;
}
nav#rellinks li+li:before {
content: "|";
}
nav#breadcrumbs li+li:before {
content: "\00BB";
}
/* Hide certain items when printing */
@media print {
div.related {
display: none;
}
}

View File

@ -0,0 +1,900 @@
/*
* basic.css
* ~~~~~~~~~
*
* Sphinx stylesheet -- basic theme.
*
* :copyright: Copyright 2007-2022 by the Sphinx team, see AUTHORS.
* :license: BSD, see LICENSE for details.
*
*/
/* -- main layout ----------------------------------------------------------- */
div.clearer {
clear: both;
}
div.section::after {
display: block;
content: '';
clear: left;
}
/* -- relbar ---------------------------------------------------------------- */
div.related {
width: 100%;
font-size: 90%;
}
div.related h3 {
display: none;
}
div.related ul {
margin: 0;
padding: 0 0 0 10px;
list-style: none;
}
div.related li {
display: inline;
}
div.related li.right {
float: right;
margin-right: 5px;
}
/* -- sidebar --------------------------------------------------------------- */
div.sphinxsidebarwrapper {
padding: 10px 5px 0 10px;
}
div.sphinxsidebar {
float: left;
width: 230px;
margin-left: -100%;
font-size: 90%;
word-wrap: break-word;
overflow-wrap : break-word;
}
div.sphinxsidebar ul {
list-style: none;
}
div.sphinxsidebar ul ul,
div.sphinxsidebar ul.want-points {
margin-left: 20px;
list-style: square;
}
div.sphinxsidebar ul ul {
margin-top: 0;
margin-bottom: 0;
}
div.sphinxsidebar form {
margin-top: 10px;
}
div.sphinxsidebar input {
border: 1px solid #98dbcc;
font-family: sans-serif;
font-size: 1em;
}
div.sphinxsidebar #searchbox form.search {
overflow: hidden;
}
div.sphinxsidebar #searchbox input[type="text"] {
float: left;
width: 80%;
padding: 0.25em;
box-sizing: border-box;
}
div.sphinxsidebar #searchbox input[type="submit"] {
float: left;
width: 20%;
border-left: none;
padding: 0.25em;
box-sizing: border-box;
}
img {
border: 0;
max-width: 100%;
}
/* -- search page ----------------------------------------------------------- */
ul.search {
margin: 10px 0 0 20px;
padding: 0;
}
ul.search li {
padding: 5px 0 5px 20px;
background-image: url(file.png);
background-repeat: no-repeat;
background-position: 0 7px;
}
ul.search li a {
font-weight: bold;
}
ul.search li p.context {
color: #888;
margin: 2px 0 0 30px;
text-align: left;
}
ul.keywordmatches li.goodmatch a {
font-weight: bold;
}
/* -- index page ------------------------------------------------------------ */
table.contentstable {
width: 90%;
margin-left: auto;
margin-right: auto;
}
table.contentstable p.biglink {
line-height: 150%;
}
a.biglink {
font-size: 1.3em;
}
span.linkdescr {
font-style: italic;
padding-top: 5px;
font-size: 90%;
}
/* -- general index --------------------------------------------------------- */
table.indextable {
width: 100%;
}
table.indextable td {
text-align: left;
vertical-align: top;
}
table.indextable ul {
margin-top: 0;
margin-bottom: 0;
list-style-type: none;
}
table.indextable > tbody > tr > td > ul {
padding-left: 0em;
}
table.indextable tr.pcap {
height: 10px;
}
table.indextable tr.cap {
margin-top: 10px;
background-color: #f2f2f2;
}
img.toggler {
margin-right: 3px;
margin-top: 3px;
cursor: pointer;
}
div.modindex-jumpbox {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
margin: 1em 0 1em 0;
padding: 0.4em;
}
div.genindex-jumpbox {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
margin: 1em 0 1em 0;
padding: 0.4em;
}
/* -- domain module index --------------------------------------------------- */
table.modindextable td {
padding: 2px;
border-collapse: collapse;
}
/* -- general body styles --------------------------------------------------- */
div.body {
min-width: 360px;
max-width: 800px;
}
div.body p, div.body dd, div.body li, div.body blockquote {
-moz-hyphens: auto;
-ms-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}
a.headerlink {
visibility: hidden;
}
h1:hover > a.headerlink,
h2:hover > a.headerlink,
h3:hover > a.headerlink,
h4:hover > a.headerlink,
h5:hover > a.headerlink,
h6:hover > a.headerlink,
dt:hover > a.headerlink,
caption:hover > a.headerlink,
p.caption:hover > a.headerlink,
div.code-block-caption:hover > a.headerlink {
visibility: visible;
}
div.body p.caption {
text-align: inherit;
}
div.body td {
text-align: left;
}
.first {
margin-top: 0 !important;
}
p.rubric {
margin-top: 30px;
font-weight: bold;
}
img.align-left, figure.align-left, .figure.align-left, object.align-left {
clear: left;
float: left;
margin-right: 1em;
}
img.align-right, figure.align-right, .figure.align-right, object.align-right {
clear: right;
float: right;
margin-left: 1em;
}
img.align-center, figure.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
img.align-default, figure.align-default, .figure.align-default {
display: block;
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.align-default {
text-align: center;
}
.align-right {
text-align: right;
}
/* -- sidebars -------------------------------------------------------------- */
div.sidebar,
aside.sidebar {
margin: 0 0 0.5em 1em;
border: 1px solid #ddb;
padding: 7px;
background-color: #ffe;
width: 40%;
float: right;
clear: right;
overflow-x: auto;
}
p.sidebar-title {
font-weight: bold;
}
nav.contents,
aside.topic,
div.admonition, div.topic, blockquote {
clear: left;
}
/* -- topics ---------------------------------------------------------------- */
nav.contents,
aside.topic,
div.topic {
border: 1px solid #ccc;
padding: 7px;
margin: 10px 0 10px 0;
}
p.topic-title {
font-size: 1.1em;
font-weight: bold;
margin-top: 10px;
}
/* -- admonitions ----------------------------------------------------------- */
div.admonition {
margin-top: 10px;
margin-bottom: 10px;
padding: 7px;
}
div.admonition dt {
font-weight: bold;
}
p.admonition-title {
margin: 0px 10px 5px 0px;
font-weight: bold;
}
div.body p.centered {
text-align: center;
margin-top: 25px;
}
/* -- content of sidebars/topics/admonitions -------------------------------- */
div.sidebar > :last-child,
aside.sidebar > :last-child,
nav.contents > :last-child,
aside.topic > :last-child,
div.topic > :last-child,
div.admonition > :last-child {
margin-bottom: 0;
}
div.sidebar::after,
aside.sidebar::after,
nav.contents::after,
aside.topic::after,
div.topic::after,
div.admonition::after,
blockquote::after {
display: block;
content: '';
clear: both;
}
/* -- tables ---------------------------------------------------------------- */
table.docutils {
margin-top: 10px;
margin-bottom: 10px;
border: 0;
border-collapse: collapse;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
table.align-default {
margin-left: auto;
margin-right: auto;
}
table caption span.caption-number {
font-style: italic;
}
table caption span.caption-text {
}
table.docutils td, table.docutils th {
padding: 1px 8px 1px 5px;
border-top: 0;
border-left: 0;
border-right: 0;
border-bottom: 1px solid #aaa;
}
th {
text-align: left;
padding-right: 5px;
}
table.citation {
border-left: solid 1px gray;
margin-left: 1px;
}
table.citation td {
border-bottom: none;
}
th > :first-child,
td > :first-child {
margin-top: 0px;
}
th > :last-child,
td > :last-child {
margin-bottom: 0px;
}
/* -- figures --------------------------------------------------------------- */
div.figure, figure {
margin: 0.5em;
padding: 0.5em;
}
div.figure p.caption, figcaption {
padding: 0.3em;
}
div.figure p.caption span.caption-number,
figcaption span.caption-number {
font-style: italic;
}
div.figure p.caption span.caption-text,
figcaption span.caption-text {
}
/* -- field list styles ----------------------------------------------------- */
table.field-list td, table.field-list th {
border: 0 !important;
}
.field-list ul {
margin: 0;
padding-left: 1em;
}
.field-list p {
margin: 0;
}
.field-name {
-moz-hyphens: manual;
-ms-hyphens: manual;
-webkit-hyphens: manual;
hyphens: manual;
}
/* -- hlist styles ---------------------------------------------------------- */
table.hlist {
margin: 1em 0;
}
table.hlist td {
vertical-align: top;
}
/* -- object description styles --------------------------------------------- */
.sig {
font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
}
.sig-name, code.descname {
background-color: transparent;
font-weight: bold;
}
.sig-name {
font-size: 1.1em;
}
code.descname {
font-size: 1.2em;
}
.sig-prename, code.descclassname {
background-color: transparent;
}
.optional {
font-size: 1.3em;
}
.sig-paren {
font-size: larger;
}
.sig-param.n {
font-style: italic;
}
/* C++ specific styling */
.sig-inline.c-texpr,
.sig-inline.cpp-texpr {
font-family: unset;
}
.sig.c .k, .sig.c .kt,
.sig.cpp .k, .sig.cpp .kt {
color: #0033B3;
}
.sig.c .m,
.sig.cpp .m {
color: #1750EB;
}
.sig.c .s, .sig.c .sc,
.sig.cpp .s, .sig.cpp .sc {
color: #067D17;
}
/* -- other body styles ----------------------------------------------------- */
ol.arabic {
list-style: decimal;
}
ol.loweralpha {
list-style: lower-alpha;
}
ol.upperalpha {
list-style: upper-alpha;
}
ol.lowerroman {
list-style: lower-roman;
}
ol.upperroman {
list-style: upper-roman;
}
:not(li) > ol > li:first-child > :first-child,
:not(li) > ul > li:first-child > :first-child {
margin-top: 0px;
}
:not(li) > ol > li:last-child > :last-child,
:not(li) > ul > li:last-child > :last-child {
margin-bottom: 0px;
}
ol.simple ol p,
ol.simple ul p,
ul.simple ol p,
ul.simple ul p {
margin-top: 0;
}
ol.simple > li:not(:first-child) > p,
ul.simple > li:not(:first-child) > p {
margin-top: 0;
}
ol.simple p,
ul.simple p {
margin-bottom: 0;
}
aside.footnote > span,
div.citation > span {
float: left;
}
aside.footnote > span:last-of-type,
div.citation > span:last-of-type {
padding-right: 0.5em;
}
aside.footnote > p {
margin-left: 2em;
}
div.citation > p {
margin-left: 4em;
}
aside.footnote > p:last-of-type,
div.citation > p:last-of-type {
margin-bottom: 0em;
}
aside.footnote > p:last-of-type:after,
div.citation > p:last-of-type:after {
content: "";
clear: both;
}
dl.field-list {
display: grid;
grid-template-columns: fit-content(30%) auto;
}
dl.field-list > dt {
font-weight: bold;
word-break: break-word;
padding-left: 0.5em;
padding-right: 5px;
}
dl.field-list > dd {
padding-left: 0.5em;
margin-top: 0em;
margin-left: 0em;
margin-bottom: 0em;
}
dl {
margin-bottom: 15px;
}
dd > :first-child {
margin-top: 0px;
}
dd ul, dd table {
margin-bottom: 10px;
}
dd {
margin-top: 3px;
margin-bottom: 10px;
margin-left: 30px;
}
dl > dd:last-child,
dl > dd:last-child > :last-child {
margin-bottom: 0;
}
dt:target, span.highlighted {
background-color: #fbe54e;
}
rect.highlighted {
fill: #fbe54e;
}
dl.glossary dt {
font-weight: bold;
font-size: 1.1em;
}
.versionmodified {
font-style: italic;
}
.system-message {
background-color: #fda;
padding: 5px;
border: 3px solid red;
}
.footnote:target {
background-color: #ffa;
}
.line-block {
display: block;
margin-top: 1em;
margin-bottom: 1em;
}
.line-block .line-block {
margin-top: 0;
margin-bottom: 0;
margin-left: 1.5em;
}
.guilabel, .menuselection {
font-family: sans-serif;
}
.accelerator {
text-decoration: underline;
}
.classifier {
font-style: oblique;
}
.classifier:before {
font-style: normal;
margin: 0 0.5em;
content: ":";
display: inline-block;
}
abbr, acronym {
border-bottom: dotted 1px;
cursor: help;
}
/* -- code displays --------------------------------------------------------- */
pre {
overflow: auto;
overflow-y: hidden; /* fixes display issues on Chrome browsers */
}
pre, div[class*="highlight-"] {
clear: both;
}
span.pre {
-moz-hyphens: none;
-ms-hyphens: none;
-webkit-hyphens: none;
hyphens: none;
white-space: nowrap;
}
div[class*="highlight-"] {
margin: 1em 0;
}
td.linenos pre {
border: 0;
background-color: transparent;
color: #aaa;
}
table.highlighttable {
display: block;
}
table.highlighttable tbody {
display: block;
}
table.highlighttable tr {
display: flex;
}
table.highlighttable td {
margin: 0;
padding: 0;
}
table.highlighttable td.linenos {
padding-right: 0.5em;
}
table.highlighttable td.code {
flex: 1;
overflow: hidden;
}
.highlight .hll {
display: block;
}
div.highlight pre,
table.highlighttable pre {
margin: 0;
}
div.code-block-caption + div {
margin-top: 0;
}
div.code-block-caption {
margin-top: 1em;
padding: 2px 5px;
font-size: small;
}
div.code-block-caption code {
background-color: transparent;
}
table.highlighttable td.linenos,
span.linenos,
div.highlight span.gp { /* gp: Generic.Prompt */
user-select: none;
-webkit-user-select: text; /* Safari fallback only */
-webkit-user-select: none; /* Chrome/Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE10+ */
}
div.code-block-caption span.caption-number {
padding: 0.1em 0.3em;
font-style: italic;
}
div.code-block-caption span.caption-text {
}
div.literal-block-wrapper {
margin: 1em 0;
}
code.xref, a code {
background-color: transparent;
font-weight: bold;
}
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
background-color: transparent;
}
.viewcode-link {
float: right;
}
.viewcode-back {
float: right;
font-family: sans-serif;
}
div.viewcode-block:target {
margin: -1px -10px;
padding: 0 10px;
}
/* -- math display ---------------------------------------------------------- */
img.math {
vertical-align: middle;
}
div.body div.math p {
text-align: center;
}
span.eqno {
float: right;
}
span.eqno a.headerlink {
position: absolute;
z-index: 1;
}
div.math:hover a.headerlink {
visibility: visible;
}
/* -- printout stylesheet --------------------------------------------------- */
@media print {
div.document,
div.documentwrapper,
div.bodywrapper {
margin: 0 !important;
width: 100%;
}
div.sphinxsidebar,
div.related,
div.footer,
#top-link {
display: none;
}
}

View File

@ -0,0 +1 @@
/* This file intentionally left blank. */

View File

@ -0,0 +1 @@
../../../../javascript/sphinxdoc/1.0/doctools.js

View File

@ -0,0 +1,14 @@
var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.1.0',
LANGUAGE: 'en',
COLLAPSE_INDEX: false,
BUILDER: 'html',
FILE_SUFFIX: '.html',
LINK_SUFFIX: '.html',
HAS_SOURCE: true,
SOURCELINK_SUFFIX: '.txt',
NAVIGATION_WITH_KEYS: false,
SHOW_SEARCH_SUMMARY: true,
ENABLE_SEARCH_SHORTCUTS: true,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

View File

@ -0,0 +1 @@
../../../../javascript/sphinxdoc/1.0/jquery.js

Some files were not shown because too many files have changed in this diff Show More