opendev: Remove content and leave an URL to the GitHub repository
Change-Id: I82a3238b6a8c21e6bb8056aca22ef71af9ea2538
This commit is contained in:
parent
bd3730e1d3
commit
4a8711e73c
.black.toml.editorconfig
.github/ISSUE_TEMPLATE
.gitignoreLICENSEREADME.rstara
api
__init__.pyadmin.pyapps.pyauth.pyfields.pyfilters.py
management/commands
migrations
0001_initial.py0002_remove_host_alias.py0003_add_missing_result_properties.py0004_duration_in_database.py0005_unique_label_names.py0006_remove_result_statuses.py0007_add_expired_status.py0008_playbook_controller.py__init__.py
models.pyserializers.pytests
__init__.pyfactories.pytests_auth.pytests_file.pytests_file_content.pytests_host.pytests_label.pytests_play.pytests_playbook.pytests_prune.pytests_records.pytests_result.pytests_task.pytests_utils.pyutils.py
urls.pyviews.pycli
clients
plugins
server
setup
README.rst__init__.pyaction_plugins.pyansible.pycallback_plugins.pyenv.pyexceptions.pylookup_plugins.pypath.pyplugins.py
ui
__init__.pyapps.pyforms.py
management/commands
pagination.pystatic
templates
@ -1,3 +0,0 @@
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
exclude = 'migrations'
|
@ -1,17 +0,0 @@
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
@ -1,21 +0,0 @@
|
||||
---
|
||||
name: "\U0001F31F Feature or improvement opportunities"
|
||||
about: Suggest an idea that would improve the project.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Thank you for taking the time to create this issue. Your feedback is appreciated ! -->
|
||||
<!-- Consider reading the documentation on https://ara.readthedocs.io/en/latest/ and joining us on Slack or IRC: https://ara.recordsansible.org/community/ -->
|
||||
|
||||
|
||||
## What is the idea ?
|
||||
<!--
|
||||
Include relevant information to help the community help you. Some examples:
|
||||
- the component that you are creating this issue about (api server, api client, web ui, ansible plugins, etc.)
|
||||
- linux distribution, version of python, version of ara and ansible
|
||||
- how is ara installed (from source, pypi, in a container, etc.) and how you are running it (database backend, wsgi server, etc.)
|
||||
-->
|
||||
|
26
.github/ISSUE_TEMPLATE/issues_bugs_problems.md
vendored
26
.github/ISSUE_TEMPLATE/issues_bugs_problems.md
vendored
@ -1,26 +0,0 @@
|
||||
---
|
||||
name: "\U0001F41E Issues, bugs and problems"
|
||||
about: Contribute a report and describe what should have happened.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Thank you for taking the time to create this issue. Your feedback is appreciated ! -->
|
||||
<!-- Consider reading the documentation on https://ara.readthedocs.io/en/latest/ and joining us on Slack or IRC: https://ara.recordsansible.org/community/ -->
|
||||
|
||||
|
||||
## What is the issue ?
|
||||
<!--
|
||||
Include relevant information to help the community help you. Some examples:
|
||||
- the component that you are creating this issue about (api server, api client, web ui, ansible plugins, etc.)
|
||||
- linux distribution, version of python, version of ara and ansible
|
||||
- how is ara installed (from source, pypi, in a container, etc.) and how you are running it (database backend, wsgi server, etc.)
|
||||
- debugging logs by setting ARA_DEBUG to True and ARA_LOG_LEVEL to DEBUG
|
||||
- instructions on how to reproduce the issue
|
||||
-->
|
||||
|
||||
|
||||
## What should be happening ?
|
||||
|
103
.gitignore
vendored
103
.gitignore
vendored
@ -1,103 +0,0 @@
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Python template
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
pip-wheel-metadata/
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
reports/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# IPython Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# dotenv
|
||||
.env
|
||||
|
||||
# vscode stuff
|
||||
.vscode
|
||||
|
||||
# virtualenv
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
db.sqlite3
|
||||
www/
|
||||
data/
|
||||
# Failed playbook integration test files
|
||||
*.retry
|
674
LICENSE
674
LICENSE
@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://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 <http://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:
|
||||
|
||||
{project} Copyright (C) {year} {fullname}
|
||||
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
|
||||
<http://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
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
205
README.rst
205
README.rst
@ -1,204 +1,5 @@
|
||||
ARA Records Ansible
|
||||
===================
|
||||
This project has been moved
|
||||
---------------------------
|
||||
|
||||
ARA Records Ansible and makes it easier to understand and troubleshoot.
|
||||
This project's code and code review is now on GitHub: https://github.com/ansible-community/ara
|
||||
|
||||
It's another recursive acronym.
|
||||
|
||||
.. image:: doc/source/_static/ara-with-icon.png
|
||||
|
||||
What it does
|
||||
============
|
||||
|
||||
Simple to install and get started, ara provides reporting by saving detailed and granular results of ``ansible`` and ``ansible-playbook`` commands wherever you run them:
|
||||
|
||||
- by hand or from a script
|
||||
- from a laptop, a desktop, a container or a server
|
||||
- for development, CI or production
|
||||
- from a linux distribution or even on OS X (as long as you have ``python >= 3.5``)
|
||||
- from tools such as AWX or Tower, Jenkins, GitLab CI, Rundeck, Zuul, Molecule, ansible-pull, ansible-test or ansible-runner
|
||||
|
||||
By default, ara's Ansible callback plugin will record data to a local sqlite database without requiring you to run a server or a service:
|
||||
|
||||
.. image:: doc/source/_static/ara-quickstart-default.gif
|
||||
|
||||
ara can also provide a single pane of glass when recording data from multiple locations by pointing the callback plugin to a running API server:
|
||||
|
||||
.. image:: doc/source/_static/ara-quickstart-server.gif
|
||||
|
||||
The data is then made available for browsing, searching and querying over the included reporting interface, a CLI client as well as a REST API.
|
||||
|
||||
How it works
|
||||
============
|
||||
|
||||
ARA Records Ansible execution results to sqlite, mysql or postgresql databases by
|
||||
using an `Ansible callback plugin <https://docs.ansible.com/ansible/latest/plugins/callback.html>`_.
|
||||
|
||||
This callback plugin leverages built-in python API clients to send data to a REST API server:
|
||||
|
||||
.. image:: doc/source/_static/graphs/recording-workflow.png
|
||||
|
||||
What it looks like
|
||||
==================
|
||||
|
||||
API browser
|
||||
-----------
|
||||
|
||||
Included by the API server with django-rest-framework, the API browser allows
|
||||
users to navigate the different API endpoints and query recorded data.
|
||||
|
||||
.. image:: doc/source/_static/ui-api-browser.png
|
||||
|
||||
Reporting interface
|
||||
-------------------
|
||||
|
||||
A simple reporting interface built-in to the API server without any extra
|
||||
dependencies.
|
||||
|
||||
.. image:: doc/source/_static/ui-playbook-details.png
|
||||
|
||||
ara CLI
|
||||
-------
|
||||
|
||||
A built-in CLI client for querying and managing playbooks and their recorded data.
|
||||
|
||||
.. image:: doc/source/_static/cli-playbook-list.png
|
||||
|
||||
The full list of commands, their arguments as well as examples can be found in
|
||||
the `CLI documentation <https://ara.readthedocs.io/en/latest/cli.html#cli-ara-api-client>`_.
|
||||
|
||||
Getting started
|
||||
===============
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
- Any recent Linux distribution or Mac OS with python >=3.5 available
|
||||
- The ara Ansible plugins must be installed for the same python interpreter as Ansible itself
|
||||
|
||||
For RHEL 7 and CentOS 7 it is recommended to run the API server in a container due to missing or outdated dependencies.
|
||||
See this `issue <https://github.com/ansible-community/ara/issues/99>`_ for more information.
|
||||
|
||||
Recording playbooks without an API server
|
||||
-----------------------------------------
|
||||
|
||||
With defaults and using a local sqlite database:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Install Ansible and ARA (with API server dependencies) for the current user
|
||||
python3 -m pip install --user ansible "ara[server]"
|
||||
|
||||
# Configure Ansible to use the ARA callback plugin
|
||||
export ANSIBLE_CALLBACK_PLUGINS="$(python3 -m ara.setup.callback_plugins)"
|
||||
|
||||
# Run an Ansible playbook
|
||||
ansible-playbook playbook.yaml
|
||||
|
||||
# Use the CLI to see recorded playbooks
|
||||
ara playbook list
|
||||
|
||||
# Start the built-in development server to browse recorded results
|
||||
ara-manage runserver
|
||||
|
||||
Recording playbooks with an API server
|
||||
--------------------------------------
|
||||
|
||||
You can get an API server deployed using the `ara Ansible collection <https://github.com/ansible-community/ara-collection>`_
|
||||
or get started quickly using the container images from `DockerHub <https://hub.docker.com/r/recordsansible/ara-api>`_ and
|
||||
`quay.io <https://quay.io/repository/recordsansible/ara-api>`_:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Create a directory for a volume to store settings and a sqlite database
|
||||
mkdir -p ~/.ara/server
|
||||
|
||||
# Start an API server with podman from the image on DockerHub:
|
||||
podman run --name api-server --detach --tty \
|
||||
--volume ~/.ara/server:/opt/ara:z -p 8000:8000 \
|
||||
docker.io/recordsansible/ara-api:latest
|
||||
|
||||
# or with docker from the image on quay.io:
|
||||
docker run --name api-server --detach --tty \
|
||||
--volume ~/.ara/server:/opt/ara:z -p 8000:8000 \
|
||||
quay.io/recordsansible/ara-api:latest
|
||||
|
||||
Once the server is running, ara's Ansible callback plugin must be installed and configured to send data to it:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Install Ansible and ARA (without API server dependencies) for the current user
|
||||
python3 -m pip install --user ansible ara
|
||||
|
||||
# Configure Ansible to use the ARA callback plugin
|
||||
export ANSIBLE_CALLBACK_PLUGINS="$(python3 -m ara.setup.callback_plugins)"
|
||||
|
||||
# Set up the ARA callback to know where the API server is located
|
||||
export ARA_API_CLIENT="http"
|
||||
export ARA_API_SERVER="http://127.0.0.1:8000"
|
||||
|
||||
# Run an Ansible playbook
|
||||
ansible-playbook playbook.yaml
|
||||
|
||||
# Use the CLI to see recorded playbooks
|
||||
ara playbook list
|
||||
|
||||
Data will be available on the API server in real time as the playbook progresses and completes.
|
||||
|
||||
You can read more about how container images are built and how to run them in the `documentation <https://ara.readthedocs.io/en/latest/container-images.html>`_.
|
||||
|
||||
Live demo
|
||||
=========
|
||||
|
||||
A live demo is deployed with the ara Ansible collection from `Ansible galaxy <https://galaxy.ansible.com/recordsansible/ara>`_.
|
||||
|
||||
It is available at https://demo.recordsansible.org.
|
||||
|
||||
Documentation
|
||||
=============
|
||||
|
||||
Documentation for installing, configuring, running and using ARA is
|
||||
available on `readthedocs.io <https://ara.readthedocs.io>`_.
|
||||
|
||||
Community and getting help
|
||||
==========================
|
||||
|
||||
- Bugs, issues and enhancements: https://github.com/ansible-community/ara/issues
|
||||
- IRC: #ara on `Freenode <https://webchat.freenode.net/?channels=#ara>`_
|
||||
- Slack: https://arecordsansible.slack.com (`invitation link <https://join.slack.com/t/arecordsansible/shared_invite/enQtMjMxNzI4ODAxMDQxLTU2NTU3YjMwYzRlYmRkZTVjZTFiOWIxNjE5NGRhMDQ3ZTgzZmQyZTY2NzY5YmZmNDA5ZWY4YTY1Y2Y1ODBmNzc>`_)
|
||||
|
||||
- Website and blog: https://ara.recordsansible.org
|
||||
- Twitter: https://twitter.com/recordsansible
|
||||
|
||||
Contributing
|
||||
============
|
||||
|
||||
Contributions to the project are welcome and appreciated !
|
||||
|
||||
Get started with the `contributor's documentation <https://ara.readthedocs.io/en/latest/contributing.html>`_.
|
||||
|
||||
Authors
|
||||
=======
|
||||
|
||||
Contributors to the project can be viewed on
|
||||
`GitHub <https://github.com/ansible-community/ara/graphs/contributors>`_.
|
||||
|
||||
Copyright
|
||||
=========
|
||||
|
||||
::
|
||||
|
||||
Copyright (c) 2021 The ARA Records Ansible authors
|
||||
|
||||
ARA Records Ansible 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.
|
||||
|
||||
ARA Records 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 ARA Records Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
@ -1,31 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from ara.api import models
|
||||
|
||||
|
||||
class RecordAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "key", "value", "type")
|
||||
search_fields = ("key", "value", "type")
|
||||
ordering = ("key",)
|
||||
|
||||
|
||||
admin.site.register(models.Record, RecordAdmin)
|
||||
admin.site.unregister(Group)
|
@ -1,22 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = "ara.api"
|
@ -1,26 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class APIAccessPermission(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return request.user.is_authenticated if settings.READ_LOGIN_REQUIRED else True
|
||||
return request.user.is_authenticated if settings.WRITE_LOGIN_REQUIRED else True
|
@ -1,116 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA Records Ansible 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.
|
||||
#
|
||||
# ARA Records 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 ARA Records Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import zlib
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from ara.api import models
|
||||
|
||||
# Constants used for defaults which rely on compression so we don't need to
|
||||
# reproduce this code elsewhere.
|
||||
EMPTY_DICT = zlib.compress(json.dumps({}).encode("utf8"))
|
||||
EMPTY_LIST = zlib.compress(json.dumps([]).encode("utf8"))
|
||||
EMPTY_STRING = zlib.compress(json.dumps("").encode("utf8"))
|
||||
|
||||
|
||||
class CompressedTextField(serializers.CharField):
|
||||
"""
|
||||
Compresses text before storing it in the database.
|
||||
Decompresses text from the database before serving it.
|
||||
"""
|
||||
|
||||
def to_representation(self, obj):
|
||||
return zlib.decompress(obj).decode("utf8")
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return zlib.compress(data.encode("utf8"))
|
||||
|
||||
|
||||
class CompressedObjectField(serializers.JSONField):
|
||||
"""
|
||||
Serializes/compresses an object (i.e, list, dict) before storing it in the
|
||||
database.
|
||||
Decompresses/deserializes an object before serving it.
|
||||
"""
|
||||
|
||||
def to_representation(self, obj):
|
||||
return json.loads(zlib.decompress(obj).decode("utf8"))
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return zlib.compress(json.dumps(data).encode("utf8"))
|
||||
|
||||
|
||||
class FileContentField(serializers.CharField):
|
||||
"""
|
||||
Compresses text before storing it in the database.
|
||||
Decompresses text from the database before serving it.
|
||||
"""
|
||||
|
||||
def to_representation(self, obj):
|
||||
return zlib.decompress(obj.contents).decode("utf8")
|
||||
|
||||
def to_internal_value(self, data):
|
||||
contents = data.encode("utf8")
|
||||
sha1 = hashlib.sha1(contents).hexdigest()
|
||||
content_file, created = models.FileContent.objects.get_or_create(
|
||||
sha1=sha1, defaults={"sha1": sha1, "contents": zlib.compress(contents)}
|
||||
)
|
||||
return content_file
|
||||
|
||||
|
||||
class CreatableSlugRelatedField(serializers.SlugRelatedField):
|
||||
"""
|
||||
A SlugRelatedField that supports get_or_create.
|
||||
Used for creating or retrieving labels by name.
|
||||
"""
|
||||
|
||||
def to_representation(self, obj):
|
||||
return {"id": obj.id, "name": obj.name}
|
||||
|
||||
# Overriding RelatedField.to_representation causes error in Browseable API
|
||||
# https://github.com/encode/django-rest-framework/issues/5141
|
||||
def get_choices(self, cutoff=None):
|
||||
queryset = self.get_queryset()
|
||||
if queryset is None:
|
||||
# Ensure that field.choices returns something sensible
|
||||
# even when accessed with a read-only field.
|
||||
return {}
|
||||
|
||||
if cutoff is not None:
|
||||
queryset = queryset[:cutoff]
|
||||
|
||||
return collections.OrderedDict(
|
||||
[
|
||||
(
|
||||
# This is the only line that differs
|
||||
# from the RelatedField's implementation
|
||||
item.pk,
|
||||
self.display_value(item),
|
||||
)
|
||||
for item in queryset
|
||||
]
|
||||
)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
return self.get_queryset().get_or_create(**{self.slug_field: data})[0]
|
||||
except (TypeError, ValueError):
|
||||
self.fail("invalid")
|
@ -1,215 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import django_filters
|
||||
from django.db import models as django_models
|
||||
|
||||
from ara.api import models as ara_models
|
||||
|
||||
|
||||
class BaseFilter(django_filters.rest_framework.FilterSet):
|
||||
created_before = django_filters.IsoDateTimeFilter(field_name="created", lookup_expr="lte")
|
||||
created_after = django_filters.IsoDateTimeFilter(field_name="created", lookup_expr="gte")
|
||||
updated_before = django_filters.IsoDateTimeFilter(field_name="updated", lookup_expr="lte")
|
||||
updated_after = django_filters.IsoDateTimeFilter(field_name="updated", lookup_expr="gte")
|
||||
|
||||
# fmt: off
|
||||
filter_overrides = {
|
||||
django_models.DateTimeField: {
|
||||
'filter_class': django_filters.IsoDateTimeFilter
|
||||
},
|
||||
}
|
||||
# fmt: on
|
||||
|
||||
|
||||
class DateFilter(BaseFilter):
|
||||
started_before = django_filters.IsoDateTimeFilter(field_name="started", lookup_expr="lte")
|
||||
started_after = django_filters.IsoDateTimeFilter(field_name="started", lookup_expr="gte")
|
||||
ended_before = django_filters.IsoDateTimeFilter(field_name="ended", lookup_expr="lte")
|
||||
ended_after = django_filters.IsoDateTimeFilter(field_name="ended", lookup_expr="gte")
|
||||
|
||||
|
||||
class LabelFilter(BaseFilter):
|
||||
# fmt: off
|
||||
order = django_filters.OrderingFilter(
|
||||
fields=(
|
||||
("id", "id"),
|
||||
("created", "created"),
|
||||
("updated", "updated")
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class PlaybookFilter(DateFilter):
|
||||
ansible_version = django_filters.CharFilter(field_name="ansible_version", lookup_expr="icontains")
|
||||
controller = django_filters.CharFilter(field_name="controller", lookup_expr="icontains")
|
||||
name = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
|
||||
path = django_filters.CharFilter(field_name="path", lookup_expr="icontains")
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
field_name="status", choices=ara_models.Playbook.STATUS, lookup_expr="iexact"
|
||||
)
|
||||
label = django_filters.CharFilter(field_name="labels", lookup_expr="name__iexact")
|
||||
|
||||
# fmt: off
|
||||
order = django_filters.OrderingFilter(
|
||||
fields=(
|
||||
("id", "id"),
|
||||
("created", "created"),
|
||||
("updated", "updated"),
|
||||
("started", "started"),
|
||||
("ended", "ended"),
|
||||
("duration", "duration"),
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class PlayFilter(DateFilter):
|
||||
playbook = django_filters.NumberFilter(field_name="playbook__id", lookup_expr="exact")
|
||||
uuid = django_filters.UUIDFilter(field_name="uuid", lookup_expr="exact")
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
field_name="status", choices=ara_models.Play.STATUS, lookup_expr="iexact"
|
||||
)
|
||||
name = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
|
||||
|
||||
# fmt: off
|
||||
order = django_filters.OrderingFilter(
|
||||
fields=(
|
||||
("id", "id"),
|
||||
("created", "created"),
|
||||
("updated", "updated"),
|
||||
("started", "started"),
|
||||
("ended", "ended"),
|
||||
("duration", "duration"),
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class TaskFilter(DateFilter):
|
||||
playbook = django_filters.NumberFilter(field_name="playbook__id", lookup_expr="exact")
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
field_name="status", choices=ara_models.Task.STATUS, lookup_expr="iexact"
|
||||
)
|
||||
name = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
|
||||
action = django_filters.CharFilter(field_name="action", lookup_expr="iexact")
|
||||
path = django_filters.CharFilter(field_name="file__path", lookup_expr="icontains")
|
||||
handler = django_filters.BooleanFilter(field_name="handler", lookup_expr="exact")
|
||||
|
||||
# fmt: off
|
||||
order = django_filters.OrderingFilter(
|
||||
fields=(
|
||||
("id", "id"),
|
||||
("created", "created"),
|
||||
("updated", "updated"),
|
||||
("started", "started"),
|
||||
("ended", "ended"),
|
||||
("duration", "duration"),
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class HostFilter(BaseFilter):
|
||||
playbook = django_filters.NumberFilter(field_name="playbook__id", lookup_expr="exact")
|
||||
name = django_filters.CharFilter(field_name="name", lookup_expr="icontains")
|
||||
|
||||
# For example: /api/v1/hosts/failed__gt=0 to return hosts with 1 failure or more
|
||||
changed__gt = django_filters.NumberFilter(field_name="changed", lookup_expr="gt")
|
||||
changed__lt = django_filters.NumberFilter(field_name="changed", lookup_expr="lt")
|
||||
failed__gt = django_filters.NumberFilter(field_name="failed", lookup_expr="gt")
|
||||
failed__lt = django_filters.NumberFilter(field_name="failed", lookup_expr="lt")
|
||||
ok__gt = django_filters.NumberFilter(field_name="ok", lookup_expr="gt")
|
||||
ok__lt = django_filters.NumberFilter(field_name="ok", lookup_expr="lt")
|
||||
skipped__gt = django_filters.NumberFilter(field_name="skipped", lookup_expr="gt")
|
||||
skipped__lt = django_filters.NumberFilter(field_name="skipped", lookup_expr="lt")
|
||||
unreachable__gt = django_filters.NumberFilter(field_name="unreachable", lookup_expr="gt")
|
||||
unreachable__lt = django_filters.NumberFilter(field_name="unreachable", lookup_expr="lt")
|
||||
|
||||
# fmt: off
|
||||
order = django_filters.OrderingFilter(
|
||||
fields=(
|
||||
("id", "id"),
|
||||
("created", "created"),
|
||||
("updated", "updated"),
|
||||
("name", "name"),
|
||||
("changed", "changed"),
|
||||
("failed", "failed"),
|
||||
("ok", "ok"),
|
||||
("skipped", "skipped"),
|
||||
("unreachable", "unreachable"),
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class ResultFilter(DateFilter):
|
||||
playbook = django_filters.NumberFilter(field_name="playbook__id", lookup_expr="exact")
|
||||
task = django_filters.NumberFilter(field_name="task__id", lookup_expr="exact")
|
||||
play = django_filters.NumberFilter(field_name="play__id", lookup_expr="exact")
|
||||
host = django_filters.NumberFilter(field_name="host__id", lookup_expr="exact")
|
||||
changed = django_filters.BooleanFilter(field_name="changed", lookup_expr="exact")
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
field_name="status", choices=ara_models.Result.STATUS, lookup_expr="iexact"
|
||||
)
|
||||
ignore_errors = django_filters.BooleanFilter(field_name="ignore_errors", lookup_expr="exact")
|
||||
|
||||
# fmt: off
|
||||
order = django_filters.OrderingFilter(
|
||||
fields=(
|
||||
("id", "id"),
|
||||
("created", "created"),
|
||||
("updated", "updated"),
|
||||
("started", "started"),
|
||||
("ended", "ended"),
|
||||
("duration", "duration"),
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class FileFilter(BaseFilter):
|
||||
playbook = django_filters.NumberFilter(field_name="playbook__id", lookup_expr="exact")
|
||||
path = django_filters.CharFilter(field_name="path", lookup_expr="icontains")
|
||||
|
||||
# fmt: off
|
||||
order = django_filters.OrderingFilter(
|
||||
fields=(
|
||||
("id", "id"),
|
||||
("created", "created"),
|
||||
("updated", "updated"),
|
||||
("path", "path")
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class RecordFilter(BaseFilter):
|
||||
playbook = django_filters.NumberFilter(field_name="playbook__id", lookup_expr="exact")
|
||||
key = django_filters.CharFilter(field_name="key", lookup_expr="exact")
|
||||
|
||||
# fmt: off
|
||||
order = django_filters.OrderingFilter(
|
||||
fields=(
|
||||
("id", "id"),
|
||||
("created", "created"),
|
||||
("updated", "updated"),
|
||||
("key", "key")
|
||||
)
|
||||
)
|
||||
# fmt: on
|
@ -1,99 +0,0 @@
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from ara.clients.utils import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Deletes playbooks from the database based on their age"
|
||||
deleted = 0
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--client",
|
||||
type=str,
|
||||
default="offline",
|
||||
help="API client to use for the query: 'offline' or 'http' (default: 'offline')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--endpoint",
|
||||
type=str,
|
||||
default="http://127.0.0.1:8000",
|
||||
help="API endpoint to use for the query (default: 'http://127.0.0.1:8000')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--username", type=str, default=None, help="API username to use for the query (default: None)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password", type=str, default=None, help="API password to use for the query (default: None)"
|
||||
)
|
||||
parser.add_argument("--insecure", action="store_true", help="Disables SSL certificate validation")
|
||||
parser.add_argument("--timeout", type=int, default=10, help="Timeout for API queries (default: 10)")
|
||||
parser.add_argument(
|
||||
"--days", type=int, default=31, help="Delete playbooks started this many days ago (default: 31)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--confirm",
|
||||
action="store_true",
|
||||
help="Confirm deletion of playbooks, otherwise runs without deleting any playbook",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
logger.warn("This command has been replaced by 'ara playbook prune' in 1.5. It will be removed in 1.6.")
|
||||
|
||||
client = options.get("client")
|
||||
endpoint = options.get("endpoint")
|
||||
username = options.get("username")
|
||||
password = options.get("password")
|
||||
insecure = options.get("insecure")
|
||||
timeout = options.get("timeout")
|
||||
days = options.get("days")
|
||||
confirm = options.get("confirm")
|
||||
|
||||
# Get an instance of either an offline or http client with the specified parameters.
|
||||
# When using the offline client, don't run SQL migrations.
|
||||
api_client = get_client(
|
||||
client=client,
|
||||
endpoint=endpoint,
|
||||
username=username,
|
||||
password=password,
|
||||
verify=False if insecure else True,
|
||||
timeout=timeout,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
if not confirm:
|
||||
logger.info("--confirm was not specified, no playbooks will be deleted")
|
||||
|
||||
# generate a timestamp from n days ago in a format we can query the API with
|
||||
# ex: 2019-11-21T00:57:41.702229
|
||||
limit_date = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
|
||||
logger.info("Querying %s/api/v1/playbooks/?started_before=%s" % (endpoint, limit_date))
|
||||
playbooks = api_client.get("/api/v1/playbooks", started_before=limit_date)
|
||||
|
||||
# TODO: Improve client validation and exception handling
|
||||
if "count" not in playbooks:
|
||||
# If we didn't get an answer we can parse, it's probably due to an error 500, 403, 401, etc.
|
||||
# The client would have logged the error.
|
||||
logger.error("Client failed to retrieve results, see logs for ara.clients.offline or ara.clients.http.")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("Found %s playbooks matching query" % playbooks["count"])
|
||||
|
||||
for playbook in playbooks["results"]:
|
||||
if not confirm:
|
||||
msg = "Dry-run: playbook {id} ({path}) would have been deleted, start date: {started}"
|
||||
logger.info(msg.format(id=playbook["id"], path=playbook["path"], started=playbook["started"]))
|
||||
else:
|
||||
msg = "Deleting playbook {id} ({path}), start date: {started}"
|
||||
logger.info(msg.format(id=playbook["id"], path=playbook["path"], started=playbook["started"]))
|
||||
api_client.delete("/api/v1/playbooks/%s" % playbook["id"])
|
||||
self.deleted += 1
|
||||
|
||||
logger.info("%s playbooks deleted" % self.deleted)
|
@ -1,192 +0,0 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-17 10:13
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='File',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('path', models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'files',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FileContent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('sha1', models.CharField(max_length=40, unique=True)),
|
||||
('contents', models.BinaryField(max_length=4294967295)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'file_contents',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Host',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('facts', models.BinaryField(max_length=4294967295)),
|
||||
('alias', models.CharField(max_length=255, null=True)),
|
||||
('changed', models.IntegerField(default=0)),
|
||||
('failed', models.IntegerField(default=0)),
|
||||
('ok', models.IntegerField(default=0)),
|
||||
('skipped', models.IntegerField(default=0)),
|
||||
('unreachable', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'hosts',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Label',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'labels',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Play',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('started', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('ended', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('uuid', models.UUIDField()),
|
||||
('status', models.CharField(choices=[('unknown', 'unknown'), ('running', 'running'), ('completed', 'completed')], default='unknown', max_length=25)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'plays',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Playbook',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('started', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('ended', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.CharField(max_length=255, null=True)),
|
||||
('ansible_version', models.CharField(max_length=255)),
|
||||
('status', models.CharField(choices=[('unknown', 'unknown'), ('running', 'running'), ('completed', 'completed'), ('failed', 'failed')], default='unknown', max_length=25)),
|
||||
('arguments', models.BinaryField(max_length=4294967295)),
|
||||
('path', models.CharField(max_length=255)),
|
||||
('labels', models.ManyToManyField(to='api.Label')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'playbooks',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Task',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('started', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('ended', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.TextField(blank=True, null=True)),
|
||||
('action', models.TextField()),
|
||||
('lineno', models.IntegerField()),
|
||||
('tags', models.BinaryField(max_length=4294967295)),
|
||||
('handler', models.BooleanField()),
|
||||
('status', models.CharField(choices=[('unknown', 'unknown'), ('running', 'running'), ('completed', 'completed')], default='unknown', max_length=25)),
|
||||
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='api.File')),
|
||||
('play', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='api.Play')),
|
||||
('playbook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='api.Playbook')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'tasks',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Result',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('started', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('ended', models.DateTimeField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('ok', 'ok'), ('failed', 'failed'), ('skipped', 'skipped'), ('unreachable', 'unreachable'), ('changed', 'changed'), ('ignored', 'ignored'), ('unknown', 'unknown')], default='unknown', max_length=25)),
|
||||
('content', models.BinaryField(max_length=4294967295)),
|
||||
('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Host')),
|
||||
('play', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Play')),
|
||||
('playbook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Playbook')),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Task')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'results',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='play',
|
||||
name='playbook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plays', to='api.Playbook'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='host',
|
||||
name='playbook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hosts', to='api.Playbook'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='file',
|
||||
name='content',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='api.FileContent'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='file',
|
||||
name='playbook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='api.Playbook'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Record',
|
||||
fields=[
|
||||
('id', models.BigAutoField(editable=False, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('key', models.CharField(max_length=255)),
|
||||
('value', models.BinaryField(max_length=4294967295)),
|
||||
('type', models.CharField(max_length=255)),
|
||||
('playbook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='api.Playbook')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'records',
|
||||
'unique_together': {('key', 'playbook')},
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='host',
|
||||
unique_together={('name', 'playbook')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='file',
|
||||
unique_together={('path', 'playbook')},
|
||||
),
|
||||
]
|
@ -1,17 +0,0 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-23 17:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='host',
|
||||
name='alias',
|
||||
),
|
||||
]
|
@ -1,23 +0,0 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-30 16:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0002_remove_host_alias'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='result',
|
||||
name='changed',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='result',
|
||||
name='ignore_errors',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@ -1,50 +0,0 @@
|
||||
# Generated by Django 2.2.7 on 2019-11-08 16:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def move_to_duration(apps, schema_editor):
|
||||
# We can't import the model directly as it may be a newer
|
||||
# version than this migration expects. We use the historical version.
|
||||
duration_models = ['Playbook', 'Play', 'Task', 'Result']
|
||||
for duration_model in duration_models:
|
||||
model = apps.get_model('api', duration_model)
|
||||
for obj in model.objects.all():
|
||||
if obj.duration is not None:
|
||||
continue
|
||||
if obj.ended is not None:
|
||||
obj.duration = obj.ended - obj.started
|
||||
else:
|
||||
obj.duration = obj.updated - obj.started
|
||||
obj.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0003_add_missing_result_properties'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='play',
|
||||
name='duration',
|
||||
field=models.DurationField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='playbook',
|
||||
name='duration',
|
||||
field=models.DurationField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='result',
|
||||
name='duration',
|
||||
field=models.DurationField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='duration',
|
||||
field=models.DurationField(blank=True, null=True),
|
||||
),
|
||||
migrations.RunPython(move_to_duration)
|
||||
]
|
@ -1,18 +0,0 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-03 17:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0004_duration_in_database'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='label',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255, unique=True),
|
||||
),
|
||||
]
|
@ -1,20 +0,0 @@
|
||||
# Generated by Django 2.2.16 on 2020-09-06 18:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0005_unique_label_names'),
|
||||
]
|
||||
|
||||
# Previously, choices included "ignored" and "changed" but these were never used
|
||||
# See: https://github.com/ansible-community/ara/issues/150
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='result',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('ok', 'ok'), ('failed', 'failed'), ('skipped', 'skipped'), ('unreachable', 'unreachable'), ('unknown', 'unknown')], default='unknown', max_length=25),
|
||||
),
|
||||
]
|
@ -1,28 +0,0 @@
|
||||
# Generated by Django 2.2.16 on 2020-09-17 12:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0006_remove_result_statuses'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='play',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('unknown', 'unknown'), ('running', 'running'), ('completed', 'completed'), ('expired', 'expired')], default='unknown', max_length=25),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='playbook',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('unknown', 'unknown'), ('expired', 'expired'), ('running', 'running'), ('completed', 'completed'), ('failed', 'failed')], default='unknown', max_length=25),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='task',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('unknown', 'unknown'), ('running', 'running'), ('completed', 'completed'), ('expired', 'expired')], default='unknown', max_length=25),
|
||||
),
|
||||
]
|
@ -1,18 +0,0 @@
|
||||
# Generated by Django 2.2.17 on 2020-12-04 04:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0007_add_expired_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='playbook',
|
||||
name='controller',
|
||||
field=models.CharField(default='localhost', max_length=255),
|
||||
),
|
||||
]
|
@ -1,282 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Base(models.Model):
|
||||
"""
|
||||
Abstract base model part of every model
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
id = models.BigAutoField(primary_key=True, editable=False)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class Duration(Base):
|
||||
"""
|
||||
Abstract model for models with a concept of duration
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
started = models.DateTimeField(default=timezone.now)
|
||||
ended = models.DateTimeField(blank=True, null=True)
|
||||
duration = models.DurationField(blank=True, null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Compute duration based on available timestamps
|
||||
if self.ended is not None:
|
||||
self.duration = self.ended - self.started
|
||||
return super(Duration, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class Label(Base):
|
||||
"""
|
||||
A label is a generic container meant to group or correlate different
|
||||
playbooks. It could be a single playbook run. It could be a "group" of
|
||||
playbooks.
|
||||
It could represent phases or dynamic logical grouping and tagging of
|
||||
playbook runs.
|
||||
You could have a label named "failures" and make it so failed playbooks
|
||||
are added to this report, for example.
|
||||
The main purpose of this is to make the labels customizable by the user.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "labels"
|
||||
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return "<Label %s: %s>" % (self.id, self.name)
|
||||
|
||||
|
||||
class Playbook(Duration):
|
||||
"""
|
||||
An entry in the 'playbooks' table represents a single execution of the
|
||||
ansible or ansible-playbook commands. All the data for that execution
|
||||
is tied back to this one playbook.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "playbooks"
|
||||
|
||||
# A playbook in ARA can be running (in progress), completed (succeeded) or failed.
|
||||
UNKNOWN = "unknown"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
EXPIRED = "expired"
|
||||
STATUS = (
|
||||
(UNKNOWN, "unknown"),
|
||||
(EXPIRED, "expired"),
|
||||
(RUNNING, "running"),
|
||||
(COMPLETED, "completed"),
|
||||
(FAILED, "failed"),
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=255, null=True)
|
||||
ansible_version = models.CharField(max_length=255)
|
||||
status = models.CharField(max_length=25, choices=STATUS, default=UNKNOWN)
|
||||
arguments = models.BinaryField(max_length=(2 ** 32) - 1)
|
||||
path = models.CharField(max_length=255)
|
||||
labels = models.ManyToManyField(Label)
|
||||
controller = models.CharField(max_length=255, default="localhost")
|
||||
|
||||
def __str__(self):
|
||||
return "<Playbook %s>" % self.id
|
||||
|
||||
|
||||
class FileContent(Base):
|
||||
"""
|
||||
Contents of a uniquely stored and compressed file.
|
||||
Running the same playbook twice will yield two playbook files but just
|
||||
one file contents.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "file_contents"
|
||||
|
||||
sha1 = models.CharField(max_length=40, unique=True)
|
||||
contents = models.BinaryField(max_length=(2 ** 32) - 1)
|
||||
|
||||
def __str__(self):
|
||||
return "<FileContent %s:%s>" % (self.id, self.sha1)
|
||||
|
||||
|
||||
class File(Base):
|
||||
"""
|
||||
Data about Ansible files (playbooks, tasks, role files, var files, etc).
|
||||
Multiple files can reference the same FileContent record.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "files"
|
||||
unique_together = ("path", "playbook")
|
||||
|
||||
path = models.CharField(max_length=255)
|
||||
content = models.ForeignKey(FileContent, on_delete=models.CASCADE, related_name="files")
|
||||
playbook = models.ForeignKey(Playbook, on_delete=models.CASCADE, related_name="files")
|
||||
|
||||
def __str__(self):
|
||||
return "<File %s:%s>" % (self.id, self.path)
|
||||
|
||||
|
||||
class Record(Base):
|
||||
"""
|
||||
A rudimentary key/value table to associate arbitrary data to a playbook.
|
||||
Used with the ara_record and ara_read Ansible modules.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "records"
|
||||
unique_together = ("key", "playbook")
|
||||
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.BinaryField(max_length=(2 ** 32) - 1)
|
||||
type = models.CharField(max_length=255)
|
||||
playbook = models.ForeignKey(Playbook, on_delete=models.CASCADE, related_name="records")
|
||||
|
||||
def __str__(self):
|
||||
return "<Record %s:%s>" % (self.id, self.key)
|
||||
|
||||
|
||||
class Play(Duration):
|
||||
"""
|
||||
Data about Ansible plays.
|
||||
Hosts, tasks and results are childrens of an Ansible play.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "plays"
|
||||
|
||||
# A play in ARA can be running (in progress) or completed (regardless of success or failure)
|
||||
UNKNOWN = "unknown"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
EXPIRED = "expired"
|
||||
STATUS = ((UNKNOWN, "unknown"), (RUNNING, "running"), (COMPLETED, "completed"), (EXPIRED, "expired"))
|
||||
|
||||
name = models.CharField(max_length=255, blank=True, null=True)
|
||||
uuid = models.UUIDField()
|
||||
status = models.CharField(max_length=25, choices=STATUS, default=UNKNOWN)
|
||||
playbook = models.ForeignKey(Playbook, on_delete=models.CASCADE, related_name="plays")
|
||||
|
||||
def __str__(self):
|
||||
return "<Play %s:%s>" % (self.id, self.name)
|
||||
|
||||
|
||||
class Task(Duration):
|
||||
"""Data about Ansible tasks."""
|
||||
|
||||
class Meta:
|
||||
db_table = "tasks"
|
||||
|
||||
# A task in ARA can be running (in progress) or completed (regardless of success or failure)
|
||||
# Actual task statuses (such as failed, skipped, etc.) are actually in the Results table.
|
||||
UNKNOWN = "unknown"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
EXPIRED = "expired"
|
||||
STATUS = ((UNKNOWN, "unknown"), (RUNNING, "running"), (COMPLETED, "completed"), (EXPIRED, "expired"))
|
||||
|
||||
name = models.TextField(blank=True, null=True)
|
||||
action = models.TextField()
|
||||
lineno = models.IntegerField()
|
||||
tags = models.BinaryField(max_length=(2 ** 32) - 1)
|
||||
handler = models.BooleanField()
|
||||
status = models.CharField(max_length=25, choices=STATUS, default=UNKNOWN)
|
||||
|
||||
play = models.ForeignKey(Play, on_delete=models.CASCADE, related_name="tasks")
|
||||
file = models.ForeignKey(File, on_delete=models.CASCADE, related_name="tasks")
|
||||
playbook = models.ForeignKey(Playbook, on_delete=models.CASCADE, related_name="tasks")
|
||||
|
||||
def __str__(self):
|
||||
return "<Task %s:%s>" % (self.name, self.id)
|
||||
|
||||
|
||||
class Host(Base):
|
||||
"""
|
||||
Data about Ansible hosts.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "hosts"
|
||||
unique_together = ("name", "playbook")
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
facts = models.BinaryField(max_length=(2 ** 32) - 1)
|
||||
|
||||
changed = models.IntegerField(default=0)
|
||||
failed = models.IntegerField(default=0)
|
||||
ok = models.IntegerField(default=0)
|
||||
skipped = models.IntegerField(default=0)
|
||||
unreachable = models.IntegerField(default=0)
|
||||
|
||||
playbook = models.ForeignKey(Playbook, on_delete=models.CASCADE, related_name="hosts")
|
||||
|
||||
def __str__(self):
|
||||
return "<Host %s:%s>" % (self.id, self.name)
|
||||
|
||||
|
||||
class Result(Duration):
|
||||
"""
|
||||
Data about Ansible results.
|
||||
A task can have many results if the task is run on multiple hosts.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "results"
|
||||
|
||||
# Ansible statuses
|
||||
OK = "ok"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
UNREACHABLE = "unreachable"
|
||||
# ARA specific status, it's the default when not specified
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
# fmt:off
|
||||
STATUS = (
|
||||
(OK, "ok"),
|
||||
(FAILED, "failed"),
|
||||
(SKIPPED, "skipped"),
|
||||
(UNREACHABLE, "unreachable"),
|
||||
(UNKNOWN, "unknown"),
|
||||
)
|
||||
# fmt:on
|
||||
|
||||
status = models.CharField(max_length=25, choices=STATUS, default=UNKNOWN)
|
||||
changed = models.BooleanField(default=False)
|
||||
ignore_errors = models.BooleanField(default=False)
|
||||
|
||||
# todo use a single Content table
|
||||
content = models.BinaryField(max_length=(2 ** 32) - 1)
|
||||
host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="results")
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="results")
|
||||
play = models.ForeignKey(Play, on_delete=models.CASCADE, related_name="results")
|
||||
playbook = models.ForeignKey(Playbook, on_delete=models.CASCADE, related_name="results")
|
||||
|
||||
def __str__(self):
|
||||
return "<Result %s, %s>" % (self.id, self.status)
|
@ -1,374 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from ara.api import fields as ara_fields, models
|
||||
|
||||
|
||||
class ResultStatusSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
status = serializers.SerializerMethodField()
|
||||
|
||||
@staticmethod
|
||||
def get_status(obj):
|
||||
if obj.status == "ok" and obj.changed:
|
||||
return "changed"
|
||||
elif obj.status == "failed" and obj.ignore_errors:
|
||||
return "ignored"
|
||||
else:
|
||||
return obj.status
|
||||
|
||||
|
||||
class TaskPathSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
path = serializers.SerializerMethodField()
|
||||
|
||||
@staticmethod
|
||||
def get_path(obj):
|
||||
return obj.file.path
|
||||
|
||||
|
||||
class ItemCountSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
# For counting relationships to other objects
|
||||
items = serializers.SerializerMethodField()
|
||||
|
||||
@staticmethod
|
||||
def get_items(obj):
|
||||
types = ["plays", "tasks", "results", "hosts", "files", "records"]
|
||||
items = {item: getattr(obj, item).count() for item in types if hasattr(obj, item)}
|
||||
return items
|
||||
|
||||
|
||||
class FileSha1Serializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
# For retrieving the sha1 of a file's contents
|
||||
sha1 = serializers.SerializerMethodField()
|
||||
|
||||
@staticmethod
|
||||
def get_sha1(obj):
|
||||
return obj.content.sha1
|
||||
|
||||
|
||||
#######
|
||||
# Simple serializers provide lightweight representations of objects suitable for inclusion in other objects
|
||||
#######
|
||||
|
||||
|
||||
class SimpleLabelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Label
|
||||
exclude = ("created", "updated")
|
||||
|
||||
|
||||
class SimplePlaybookSerializer(ItemCountSerializer):
|
||||
class Meta:
|
||||
model = models.Playbook
|
||||
exclude = ("arguments", "created", "updated")
|
||||
|
||||
labels = SimpleLabelSerializer(many=True, read_only=True, default=[])
|
||||
|
||||
|
||||
class SimplePlaySerializer(ItemCountSerializer):
|
||||
class Meta:
|
||||
model = models.Play
|
||||
exclude = ("playbook", "uuid", "created", "updated")
|
||||
|
||||
|
||||
class SimpleTaskSerializer(ItemCountSerializer, TaskPathSerializer):
|
||||
class Meta:
|
||||
model = models.Task
|
||||
exclude = ("playbook", "play", "created", "updated")
|
||||
|
||||
tags = ara_fields.CompressedObjectField(read_only=True)
|
||||
|
||||
|
||||
class SimpleHostSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Host
|
||||
exclude = ("playbook", "facts", "created", "updated")
|
||||
|
||||
|
||||
class SimpleFileSerializer(FileSha1Serializer):
|
||||
class Meta:
|
||||
model = models.File
|
||||
exclude = ("playbook", "content", "created", "updated")
|
||||
|
||||
|
||||
#######
|
||||
# Detailed serializers returns every field of an object as well as a simple
|
||||
# representation of relationships to other objects.
|
||||
#######
|
||||
|
||||
|
||||
class DetailedLabelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Label
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class DetailedPlaybookSerializer(ItemCountSerializer):
|
||||
class Meta:
|
||||
model = models.Playbook
|
||||
fields = "__all__"
|
||||
|
||||
arguments = ara_fields.CompressedObjectField(default=ara_fields.EMPTY_DICT, read_only=True)
|
||||
labels = SimpleLabelSerializer(many=True, read_only=True, default=[])
|
||||
|
||||
|
||||
class DetailedPlaySerializer(ItemCountSerializer):
|
||||
class Meta:
|
||||
model = models.Play
|
||||
fields = "__all__"
|
||||
|
||||
playbook = SimplePlaybookSerializer(read_only=True)
|
||||
|
||||
|
||||
class DetailedTaskSerializer(ItemCountSerializer, TaskPathSerializer):
|
||||
class Meta:
|
||||
model = models.Task
|
||||
fields = "__all__"
|
||||
|
||||
playbook = SimplePlaybookSerializer(read_only=True)
|
||||
play = SimplePlaySerializer(read_only=True)
|
||||
file = SimpleFileSerializer(read_only=True)
|
||||
tags = ara_fields.CompressedObjectField(read_only=True)
|
||||
|
||||
|
||||
class DetailedHostSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Host
|
||||
fields = "__all__"
|
||||
|
||||
playbook = SimplePlaybookSerializer(read_only=True)
|
||||
facts = ara_fields.CompressedObjectField(read_only=True)
|
||||
|
||||
|
||||
class DetailedResultSerializer(ResultStatusSerializer):
|
||||
class Meta:
|
||||
model = models.Result
|
||||
fields = "__all__"
|
||||
|
||||
playbook = SimplePlaybookSerializer(read_only=True)
|
||||
play = SimplePlaySerializer(read_only=True)
|
||||
task = SimpleTaskSerializer(read_only=True)
|
||||
host = SimpleHostSerializer(read_only=True)
|
||||
content = ara_fields.CompressedObjectField(read_only=True)
|
||||
|
||||
|
||||
class DetailedFileSerializer(FileSha1Serializer):
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = "__all__"
|
||||
|
||||
playbook = SimplePlaybookSerializer(read_only=True)
|
||||
content = ara_fields.FileContentField(read_only=True)
|
||||
|
||||
|
||||
class DetailedRecordSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Record
|
||||
fields = "__all__"
|
||||
|
||||
playbook = SimplePlaybookSerializer(read_only=True)
|
||||
value = ara_fields.CompressedObjectField(read_only=True)
|
||||
|
||||
|
||||
#######
|
||||
# List serializers returns lightweight fields about objects.
|
||||
# Relationships are represented by numerical IDs.
|
||||
#######
|
||||
|
||||
|
||||
class ListLabelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Label
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ListPlaybookSerializer(ItemCountSerializer):
|
||||
class Meta:
|
||||
model = models.Playbook
|
||||
fields = "__all__"
|
||||
|
||||
arguments = ara_fields.CompressedObjectField(default=ara_fields.EMPTY_DICT, read_only=True)
|
||||
labels = SimpleLabelSerializer(many=True, read_only=True, default=[])
|
||||
|
||||
|
||||
class ListPlaySerializer(ItemCountSerializer):
|
||||
class Meta:
|
||||
model = models.Play
|
||||
fields = "__all__"
|
||||
|
||||
playbook = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
|
||||
class ListTaskSerializer(ItemCountSerializer, TaskPathSerializer):
|
||||
class Meta:
|
||||
model = models.Task
|
||||
fields = "__all__"
|
||||
|
||||
tags = ara_fields.CompressedObjectField(read_only=True)
|
||||
play = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
|
||||
class ListHostSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Host
|
||||
exclude = ("facts",)
|
||||
|
||||
playbook = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
|
||||
class ListResultSerializer(ResultStatusSerializer):
|
||||
class Meta:
|
||||
model = models.Result
|
||||
exclude = ("content",)
|
||||
|
||||
playbook = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
play = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
task = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
host = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
|
||||
class ListFileSerializer(FileSha1Serializer):
|
||||
class Meta:
|
||||
model = models.File
|
||||
exclude = ("content",)
|
||||
|
||||
playbook = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
|
||||
class ListRecordSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Record
|
||||
exclude = ("value",)
|
||||
|
||||
playbook = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
|
||||
#######
|
||||
# Default serializers represents objects as they are modelized in the database.
|
||||
# They are used for creating/updating/destroying objects.
|
||||
#######
|
||||
|
||||
|
||||
class LabelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Label
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class PlaybookSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Playbook
|
||||
fields = "__all__"
|
||||
|
||||
arguments = ara_fields.CompressedObjectField(default=ara_fields.EMPTY_DICT)
|
||||
labels = ara_fields.CreatableSlugRelatedField(
|
||||
many=True, slug_field="name", queryset=models.Label.objects.all(), required=False
|
||||
)
|
||||
|
||||
|
||||
class PlaySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Play
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TaskSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Task
|
||||
fields = "__all__"
|
||||
|
||||
tags = ara_fields.CompressedObjectField(default=ara_fields.EMPTY_LIST, help_text="A list containing Ansible tags")
|
||||
|
||||
|
||||
class HostSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Host
|
||||
fields = "__all__"
|
||||
|
||||
facts = ara_fields.CompressedObjectField(default=ara_fields.EMPTY_DICT)
|
||||
|
||||
def get_unique_together_validators(self):
|
||||
"""
|
||||
Hosts have a "unique together" constraint for host.name and play.id.
|
||||
We want to have a "get_or_create" facility and in order to do that, we
|
||||
must manage the validation during the creation, not before.
|
||||
Overriding this method effectively disables this validator.
|
||||
"""
|
||||
return []
|
||||
|
||||
def create(self, validated_data):
|
||||
host, created = models.Host.objects.get_or_create(
|
||||
name=validated_data["name"], playbook=validated_data["playbook"], defaults=validated_data
|
||||
)
|
||||
return host
|
||||
|
||||
|
||||
class ResultSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Result
|
||||
fields = "__all__"
|
||||
|
||||
content = ara_fields.CompressedObjectField(default=ara_fields.EMPTY_DICT)
|
||||
|
||||
|
||||
class FileSerializer(FileSha1Serializer):
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = "__all__"
|
||||
|
||||
content = ara_fields.FileContentField()
|
||||
|
||||
def get_unique_together_validators(self):
|
||||
"""
|
||||
Files have a "unique together" constraint for file.path and playbook.id.
|
||||
We want to have a "get_or_create" facility and in order to do that, we
|
||||
must manage the validation during the creation, not before.
|
||||
Overriding this method effectively disables this validator.
|
||||
"""
|
||||
return []
|
||||
|
||||
def create(self, validated_data):
|
||||
file_, created = models.File.objects.get_or_create(
|
||||
path=validated_data["path"],
|
||||
content=validated_data["content"],
|
||||
playbook=validated_data["playbook"],
|
||||
defaults=validated_data,
|
||||
)
|
||||
return file_
|
||||
|
||||
|
||||
class RecordSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Record
|
||||
fields = "__all__"
|
||||
|
||||
value = ara_fields.CompressedObjectField(
|
||||
default=ara_fields.EMPTY_STRING, help_text="A string, list, dict, json or other formatted data"
|
||||
)
|
@ -1,139 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
|
||||
import factory
|
||||
|
||||
try:
|
||||
from factory import DjangoModelFactory
|
||||
except ImportError:
|
||||
# >3.0 moved the location of DjangoModelFactory
|
||||
from factory.django import DjangoModelFactory
|
||||
|
||||
from ara.api import models
|
||||
from ara.api.tests import utils
|
||||
|
||||
logging.getLogger("factory").setLevel(logging.INFO)
|
||||
|
||||
# constants for things like compressed byte strings or objects
|
||||
FILE_CONTENTS = "---\n# Example file"
|
||||
HOST_FACTS = {"ansible_fqdn": "hostname", "ansible_distribution": "CentOS"}
|
||||
PLAYBOOK_ARGUMENTS = {"ansible_version": "2.5.5", "inventory": "/etc/ansible/hosts"}
|
||||
RESULT_CONTENTS = {"results": [{"msg": "something happened"}]}
|
||||
TASK_TAGS = ["always", "never"]
|
||||
RECORD_LIST = ["one", "two", "three"]
|
||||
|
||||
|
||||
class PlaybookFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Playbook
|
||||
|
||||
controller = "localhost"
|
||||
name = "test-playbook"
|
||||
ansible_version = "2.4.0"
|
||||
status = "running"
|
||||
arguments = utils.compressed_obj(PLAYBOOK_ARGUMENTS)
|
||||
path = "/path/playbook.yml"
|
||||
|
||||
|
||||
class FileContentFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.FileContent
|
||||
django_get_or_create = ("sha1",)
|
||||
|
||||
sha1 = utils.sha1(FILE_CONTENTS)
|
||||
contents = utils.compressed_str(FILE_CONTENTS)
|
||||
|
||||
|
||||
class FileFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.File
|
||||
|
||||
path = "/path/playbook.yml"
|
||||
content = factory.SubFactory(FileContentFactory)
|
||||
playbook = factory.SubFactory(PlaybookFactory)
|
||||
|
||||
|
||||
class LabelFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Label
|
||||
|
||||
name = "test label"
|
||||
|
||||
|
||||
class PlayFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Play
|
||||
|
||||
name = "test play"
|
||||
status = "running"
|
||||
uuid = "5c5f67b9-e63c-6297-80da-000000000005"
|
||||
playbook = factory.SubFactory(PlaybookFactory)
|
||||
|
||||
|
||||
class TaskFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Task
|
||||
|
||||
name = "test task"
|
||||
status = "running"
|
||||
action = "setup"
|
||||
lineno = 2
|
||||
handler = False
|
||||
tags = utils.compressed_obj(TASK_TAGS)
|
||||
play = factory.SubFactory(PlayFactory)
|
||||
file = factory.SubFactory(FileFactory)
|
||||
playbook = factory.SubFactory(PlaybookFactory)
|
||||
|
||||
|
||||
class HostFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Host
|
||||
|
||||
facts = utils.compressed_obj(HOST_FACTS)
|
||||
name = "hostname"
|
||||
playbook = factory.SubFactory(PlaybookFactory)
|
||||
changed = 0
|
||||
failed = 0
|
||||
ok = 0
|
||||
skipped = 0
|
||||
unreachable = 0
|
||||
|
||||
|
||||
class ResultFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Result
|
||||
|
||||
content = utils.compressed_obj(RESULT_CONTENTS)
|
||||
status = "ok"
|
||||
host = factory.SubFactory(HostFactory)
|
||||
task = factory.SubFactory(TaskFactory)
|
||||
play = factory.SubFactory(PlayFactory)
|
||||
playbook = factory.SubFactory(PlaybookFactory)
|
||||
changed = False
|
||||
ignore_errors = False
|
||||
|
||||
|
||||
class RecordFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = models.Record
|
||||
|
||||
key = "record-key"
|
||||
value = utils.compressed_obj(RECORD_LIST)
|
||||
type = "list"
|
||||
playbook = factory.SubFactory(PlaybookFactory)
|
@ -1,74 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
|
||||
from ara.api.auth import APIAccessPermission
|
||||
|
||||
|
||||
class User:
|
||||
is_authenticated = True
|
||||
|
||||
|
||||
class AnonymousUser(User):
|
||||
is_authenticated = False
|
||||
|
||||
|
||||
class PermissionBackendTestCase(TestCase):
|
||||
def setUp(self):
|
||||
factory = RequestFactory()
|
||||
self.anon_get_request = factory.get("/")
|
||||
self.anon_get_request.user = AnonymousUser()
|
||||
self.anon_post_request = factory.post("/")
|
||||
self.anon_post_request.user = AnonymousUser()
|
||||
|
||||
self.authed_get_request = factory.get("/")
|
||||
self.authed_get_request.user = User()
|
||||
self.authed_post_request = factory.post("/")
|
||||
self.authed_post_request.user = User()
|
||||
|
||||
@override_settings(READ_LOGIN_REQUIRED=False, WRITE_LOGIN_REQUIRED=True)
|
||||
def test_anonymous_read_access(self):
|
||||
backend = APIAccessPermission()
|
||||
|
||||
# Writes are blocked (just to show it has no affect on read)
|
||||
self.assertFalse(backend.has_permission(self.anon_post_request, None))
|
||||
|
||||
# Reads are allowed based on READ_LOGIN_REQUIRED
|
||||
self.assertTrue(backend.has_permission(self.anon_get_request, None))
|
||||
settings.READ_LOGIN_REQUIRED = True
|
||||
self.assertFalse(backend.has_permission(self.anon_get_request, None))
|
||||
|
||||
@override_settings(READ_LOGIN_REQUIRED=True, WRITE_LOGIN_REQUIRED=False)
|
||||
def test_anonymous_write_access(self):
|
||||
backend = APIAccessPermission()
|
||||
|
||||
# Reads are blocked (just to show it has no affect on write)
|
||||
self.assertFalse(backend.has_permission(self.anon_get_request, None))
|
||||
|
||||
# Writes are allowed based on WRITE_LOGIN_REQUIRED
|
||||
self.assertTrue(backend.has_permission(self.anon_post_request, None))
|
||||
settings.WRITE_LOGIN_REQUIRED = True
|
||||
self.assertFalse(backend.has_permission(self.anon_post_request, None))
|
||||
|
||||
@override_settings(READ_LOGIN_REQUIRED=True, WRITE_LOGIN_REQUIRED=True)
|
||||
def test_auth_access(self):
|
||||
backend = APIAccessPermission()
|
||||
|
||||
self.assertTrue(backend.has_permission(self.authed_get_request, None))
|
||||
self.assertTrue(backend.has_permission(self.authed_post_request, None))
|
@ -1,180 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api import models, serializers
|
||||
from ara.api.tests import factories, utils
|
||||
|
||||
|
||||
class FileTestCase(APITestCase):
|
||||
def test_file_factory(self):
|
||||
file_content = factories.FileContentFactory()
|
||||
file = factories.FileFactory(path="/path/playbook.yml", content=file_content)
|
||||
self.assertEqual(file.path, "/path/playbook.yml")
|
||||
self.assertEqual(file.content.sha1, file_content.sha1)
|
||||
|
||||
def test_file_serializer(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
serializer = serializers.FileSerializer(
|
||||
data={"path": "/path/playbook.yml", "content": factories.FILE_CONTENTS, "playbook": playbook.id}
|
||||
)
|
||||
serializer.is_valid()
|
||||
file = serializer.save()
|
||||
file.refresh_from_db()
|
||||
self.assertEqual(file.content.sha1, utils.sha1(factories.FILE_CONTENTS))
|
||||
|
||||
def test_create_file_with_same_content_create_only_one_file_content(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
serializer = serializers.FileSerializer(
|
||||
data={"path": "/path/1/playbook.yml", "content": factories.FILE_CONTENTS, "playbook": playbook.id}
|
||||
)
|
||||
serializer.is_valid()
|
||||
file_content = serializer.save()
|
||||
file_content.refresh_from_db()
|
||||
|
||||
serializer2 = serializers.FileSerializer(
|
||||
data={"path": "/path/2/playbook.yml", "content": factories.FILE_CONTENTS, "playbook": playbook.id}
|
||||
)
|
||||
serializer2.is_valid()
|
||||
file_content = serializer2.save()
|
||||
file_content.refresh_from_db()
|
||||
|
||||
self.assertEqual(2, models.File.objects.all().count())
|
||||
self.assertEqual(1, models.FileContent.objects.all().count())
|
||||
|
||||
def test_create_file(self):
|
||||
self.assertEqual(0, models.File.objects.count())
|
||||
playbook = factories.PlaybookFactory()
|
||||
request = self.client.post(
|
||||
"/api/v1/files", {"path": "/path/playbook.yml", "content": factories.FILE_CONTENTS, "playbook": playbook.id}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.File.objects.count())
|
||||
|
||||
def test_post_same_file_for_a_playbook(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.File.objects.count())
|
||||
request = self.client.post(
|
||||
"/api/v1/files", {"path": "/path/playbook.yml", "content": factories.FILE_CONTENTS, "playbook": playbook.id}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.File.objects.count())
|
||||
|
||||
request = self.client.post(
|
||||
"/api/v1/files", {"path": "/path/playbook.yml", "content": factories.FILE_CONTENTS, "playbook": playbook.id}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.File.objects.count())
|
||||
|
||||
def test_get_no_files(self):
|
||||
request = self.client.get("/api/v1/files")
|
||||
self.assertEqual(0, len(request.data["results"]))
|
||||
|
||||
def test_get_files(self):
|
||||
file = factories.FileFactory()
|
||||
request = self.client.get("/api/v1/files")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(file.path, request.data["results"][0]["path"])
|
||||
|
||||
def test_get_file(self):
|
||||
file = factories.FileFactory()
|
||||
request = self.client.get("/api/v1/files/%s" % file.id)
|
||||
self.assertEqual(file.path, request.data["path"])
|
||||
self.assertEqual(file.content.sha1, request.data["sha1"])
|
||||
|
||||
def test_update_file(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
file = factories.FileFactory(playbook=playbook)
|
||||
old_sha1 = file.content.sha1
|
||||
self.assertNotEqual("/path/new_playbook.yml", file.path)
|
||||
request = self.client.put(
|
||||
"/api/v1/files/%s" % file.id,
|
||||
{"path": "/path/new_playbook.yml", "content": "# playbook", "playbook": playbook.id},
|
||||
)
|
||||
self.assertEqual(200, request.status_code)
|
||||
file_updated = models.File.objects.get(id=file.id)
|
||||
self.assertEqual("/path/new_playbook.yml", file_updated.path)
|
||||
self.assertNotEqual(old_sha1, file_updated.content.sha1)
|
||||
|
||||
def test_partial_update_file(self):
|
||||
file = factories.FileFactory()
|
||||
self.assertNotEqual("/path/new_playbook.yml", file.path)
|
||||
request = self.client.patch("/api/v1/files/%s" % file.id, {"path": "/path/new_playbook.yml"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
file_updated = models.File.objects.get(id=file.id)
|
||||
self.assertEqual("/path/new_playbook.yml", file_updated.path)
|
||||
|
||||
def test_delete_file(self):
|
||||
file = factories.FileFactory()
|
||||
self.assertEqual(1, models.File.objects.all().count())
|
||||
request = self.client.delete("/api/v1/files/%s" % file.id)
|
||||
self.assertEqual(204, request.status_code)
|
||||
self.assertEqual(0, models.File.objects.all().count())
|
||||
|
||||
def test_get_file_by_date(self):
|
||||
file = factories.FileFactory()
|
||||
|
||||
past = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
negative_date_fields = ["created_before", "updated_before"]
|
||||
positive_date_fields = ["created_after", "updated_after"]
|
||||
|
||||
# Expect no file when searching before it was created
|
||||
for field in negative_date_fields:
|
||||
request = self.client.get("/api/v1/files?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 0)
|
||||
|
||||
# Expect a file when searching after it was created
|
||||
for field in positive_date_fields:
|
||||
request = self.client.get("/api/v1/files?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 1)
|
||||
self.assertEqual(request.data["results"][0]["id"], file.id)
|
||||
|
||||
def test_get_file_order(self):
|
||||
first_file = factories.FileFactory(path="/root/file.yaml")
|
||||
second_file = factories.FileFactory(path="/root/some/path/file.yaml")
|
||||
|
||||
# Ensure we have two objects
|
||||
request = self.client.get("/api/v1/files")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
order_fields = ["id", "created", "updated", "path"]
|
||||
# Ascending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/files?order=%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], first_file.id)
|
||||
|
||||
# Descending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/files?order=-%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], second_file.id)
|
||||
|
||||
def test_get_file_by_path(self):
|
||||
# Create two files with similar paths
|
||||
first_file = factories.FileFactory(path="/root/file.yaml")
|
||||
factories.FileFactory(path="/root/some/path/file.yaml")
|
||||
|
||||
# Exact search should match one
|
||||
request = self.client.get("/api/v1/files?path=/root/file.yaml")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(first_file.path, request.data["results"][0]["path"])
|
||||
|
||||
# Partial match should match both files
|
||||
request = self.client.get("/api/v1/files?path=file.yaml")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
@ -1,26 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api.tests import factories
|
||||
|
||||
|
||||
class FileContentTestCase(APITestCase):
|
||||
def test_file_content_factory(self):
|
||||
file_content = factories.FileContentFactory(sha1="413a2f16b8689267b7d0c2e10cdd19bf3e54208d")
|
||||
self.assertEqual(file_content.sha1, "413a2f16b8689267b7d0c2e10cdd19bf3e54208d")
|
@ -1,184 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api import models, serializers
|
||||
from ara.api.tests import factories, utils
|
||||
|
||||
|
||||
class HostTestCase(APITestCase):
|
||||
def test_host_factory(self):
|
||||
host = factories.HostFactory(name="testhost")
|
||||
self.assertEqual(host.name, "testhost")
|
||||
|
||||
def test_host_serializer(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
serializer = serializers.HostSerializer(data={"name": "serializer", "playbook": playbook.id})
|
||||
serializer.is_valid()
|
||||
host = serializer.save()
|
||||
host.refresh_from_db()
|
||||
self.assertEqual(host.name, "serializer")
|
||||
self.assertEqual(host.playbook.id, playbook.id)
|
||||
|
||||
def test_host_serializer_compress_facts(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
serializer = serializers.HostSerializer(
|
||||
data={"name": "compress", "facts": factories.HOST_FACTS, "playbook": playbook.id}
|
||||
)
|
||||
serializer.is_valid()
|
||||
host = serializer.save()
|
||||
host.refresh_from_db()
|
||||
self.assertEqual(host.facts, utils.compressed_obj(factories.HOST_FACTS))
|
||||
|
||||
def test_host_serializer_decompress_facts(self):
|
||||
host = factories.HostFactory(facts=utils.compressed_obj(factories.HOST_FACTS))
|
||||
serializer = serializers.HostSerializer(instance=host)
|
||||
self.assertEqual(serializer.data["facts"], factories.HOST_FACTS)
|
||||
|
||||
def test_get_no_hosts(self):
|
||||
request = self.client.get("/api/v1/hosts")
|
||||
self.assertEqual(0, len(request.data["results"]))
|
||||
|
||||
def test_get_hosts(self):
|
||||
host = factories.HostFactory()
|
||||
request = self.client.get("/api/v1/hosts")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(host.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_delete_host(self):
|
||||
host = factories.HostFactory()
|
||||
self.assertEqual(1, models.Host.objects.all().count())
|
||||
request = self.client.delete("/api/v1/hosts/%s" % host.id)
|
||||
self.assertEqual(204, request.status_code)
|
||||
self.assertEqual(0, models.Host.objects.all().count())
|
||||
|
||||
def test_create_host(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.Host.objects.count())
|
||||
request = self.client.post("/api/v1/hosts", {"name": "create", "playbook": playbook.id})
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Host.objects.count())
|
||||
|
||||
def test_post_same_host_for_a_playbook(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.Host.objects.count())
|
||||
request = self.client.post("/api/v1/hosts", {"name": "create", "playbook": playbook.id})
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Host.objects.count())
|
||||
|
||||
request = self.client.post("/api/v1/hosts", {"name": "create", "playbook": playbook.id})
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Host.objects.count())
|
||||
|
||||
def test_partial_update_host(self):
|
||||
host = factories.HostFactory()
|
||||
self.assertNotEqual("foo", host.name)
|
||||
request = self.client.patch("/api/v1/hosts/%s" % host.id, {"name": "foo"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
host_updated = models.Host.objects.get(id=host.id)
|
||||
self.assertEqual("foo", host_updated.name)
|
||||
|
||||
def test_get_host(self):
|
||||
host = factories.HostFactory()
|
||||
request = self.client.get("/api/v1/hosts/%s" % host.id)
|
||||
self.assertEqual(host.name, request.data["name"])
|
||||
|
||||
def test_get_hosts_by_playbook(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
host = factories.HostFactory(name="host1", playbook=playbook)
|
||||
factories.HostFactory(name="host2", playbook=playbook)
|
||||
request = self.client.get("/api/v1/hosts?playbook=%s" % playbook.id)
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
self.assertEqual(host.name, request.data["results"][0]["name"])
|
||||
self.assertEqual("host2", request.data["results"][1]["name"])
|
||||
|
||||
def test_get_hosts_by_name(self):
|
||||
# Create a playbook and two hosts
|
||||
playbook = factories.PlaybookFactory()
|
||||
host = factories.HostFactory(name="host1", playbook=playbook)
|
||||
factories.HostFactory(name="host2", playbook=playbook)
|
||||
|
||||
# Query for the first host name and expect one result
|
||||
request = self.client.get("/api/v1/hosts?name=%s" % host.name)
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(host.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_get_hosts_by_stats(self):
|
||||
# Create two hosts with different stats
|
||||
first_host = factories.HostFactory(name="first_host", changed=2, failed=2, ok=2, skipped=2, unreachable=2)
|
||||
second_host = factories.HostFactory(name="second_host", changed=0, failed=0, ok=0, skipped=0, unreachable=0)
|
||||
|
||||
# There must be two distinct hosts
|
||||
request = self.client.get("/api/v1/hosts")
|
||||
self.assertEqual(2, request.data["count"])
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
statuses = ["changed", "failed", "ok", "skipped", "unreachable"]
|
||||
|
||||
# Searching for > should only return the first host
|
||||
for status in statuses:
|
||||
request = self.client.get("/api/v1/hosts?%s__gt=1" % status)
|
||||
self.assertEqual(1, request.data["count"])
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(first_host.id, request.data["results"][0]["id"])
|
||||
|
||||
# Searching for < should only return the second host
|
||||
for status in statuses:
|
||||
request = self.client.get("/api/v1/hosts?%s__lt=1" % status)
|
||||
self.assertEqual(1, request.data["count"])
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(second_host.id, request.data["results"][0]["id"])
|
||||
|
||||
def test_get_host_by_date(self):
|
||||
host = factories.HostFactory()
|
||||
|
||||
past = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
negative_date_fields = ["created_before", "updated_before"]
|
||||
positive_date_fields = ["created_after", "updated_after"]
|
||||
|
||||
# Expect no host when searching before it was created
|
||||
for field in negative_date_fields:
|
||||
request = self.client.get("/api/v1/hosts?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 0)
|
||||
|
||||
# Expect a host when searching after it was created
|
||||
for field in positive_date_fields:
|
||||
request = self.client.get("/api/v1/hosts?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 1)
|
||||
self.assertEqual(request.data["results"][0]["id"], host.id)
|
||||
|
||||
def test_get_host_order(self):
|
||||
first_host = factories.HostFactory(name="alpha")
|
||||
second_host = factories.HostFactory(name="beta", changed=10, failed=10, ok=10, skipped=10, unreachable=10)
|
||||
|
||||
# Ensure we have two objects
|
||||
request = self.client.get("/api/v1/hosts")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
order_fields = ["id", "created", "updated", "name", "changed", "failed", "ok", "skipped", "unreachable"]
|
||||
# Ascending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/hosts?order=%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], first_host.id)
|
||||
|
||||
# Descending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/hosts?order=-%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], second_host.id)
|
@ -1,117 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db.utils import IntegrityError
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api import models, serializers
|
||||
from ara.api.tests import factories
|
||||
|
||||
|
||||
class LabelTestCase(APITestCase):
|
||||
def test_label_factory(self):
|
||||
label = factories.LabelFactory(name="factory")
|
||||
self.assertEqual(label.name, "factory")
|
||||
|
||||
def test_label_serializer(self):
|
||||
serializer = serializers.LabelSerializer(data={"name": "serializer"})
|
||||
serializer.is_valid()
|
||||
label = serializer.save()
|
||||
label.refresh_from_db()
|
||||
self.assertEqual(label.name, "serializer")
|
||||
|
||||
def test_create_label(self):
|
||||
self.assertEqual(0, models.Label.objects.count())
|
||||
request = self.client.post("/api/v1/labels", {"name": "compress"})
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Label.objects.count())
|
||||
|
||||
def test_get_no_labels(self):
|
||||
request = self.client.get("/api/v1/labels")
|
||||
self.assertEqual(0, len(request.data["results"]))
|
||||
|
||||
def test_get_labels(self):
|
||||
label = factories.LabelFactory()
|
||||
request = self.client.get("/api/v1/labels")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(label.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_get_label(self):
|
||||
label = factories.LabelFactory()
|
||||
request = self.client.get("/api/v1/labels/%s" % label.id)
|
||||
self.assertEqual(label.name, request.data["name"])
|
||||
|
||||
def test_partial_update_label(self):
|
||||
label = factories.LabelFactory()
|
||||
self.assertNotEqual("updated", label.name)
|
||||
request = self.client.patch("/api/v1/labels/%s" % label.id, {"name": "updated"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
label_updated = models.Label.objects.get(id=label.id)
|
||||
self.assertEqual("updated", label_updated.name)
|
||||
|
||||
def test_delete_label(self):
|
||||
label = factories.LabelFactory()
|
||||
self.assertEqual(1, models.Label.objects.all().count())
|
||||
request = self.client.delete("/api/v1/labels/%s" % label.id)
|
||||
self.assertEqual(204, request.status_code)
|
||||
self.assertEqual(0, models.Label.objects.all().count())
|
||||
|
||||
def test_get_label_by_date(self):
|
||||
label = factories.LabelFactory()
|
||||
|
||||
past = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
negative_date_fields = ["created_before", "updated_before"]
|
||||
positive_date_fields = ["created_after", "updated_after"]
|
||||
|
||||
# Expect no label when searching before it was created
|
||||
for field in negative_date_fields:
|
||||
request = self.client.get("/api/v1/labels?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 0)
|
||||
|
||||
# Expect a label when searching after it was created
|
||||
for field in positive_date_fields:
|
||||
request = self.client.get("/api/v1/labels?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 1)
|
||||
self.assertEqual(request.data["results"][0]["id"], label.id)
|
||||
|
||||
def test_get_label_order(self):
|
||||
first_label = factories.LabelFactory(name="first")
|
||||
second_label = factories.LabelFactory(name="second")
|
||||
|
||||
# Ensure we have two objects
|
||||
request = self.client.get("/api/v1/labels")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
order_fields = ["id", "created", "updated"]
|
||||
# Ascending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/labels?order=%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["name"], first_label.name)
|
||||
|
||||
# Descending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/labels?order=-%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["name"], second_label.name)
|
||||
|
||||
def test_unique_label_names(self):
|
||||
# Create a first label
|
||||
factories.LabelFactory(name="label")
|
||||
with self.assertRaises(IntegrityError):
|
||||
# Creating a second label with the same name should yield an exception
|
||||
factories.LabelFactory(name="label")
|
@ -1,200 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_duration
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api import models, serializers
|
||||
from ara.api.tests import factories
|
||||
|
||||
|
||||
class PlayTestCase(APITestCase):
|
||||
def test_play_factory(self):
|
||||
play = factories.PlayFactory(name="play factory")
|
||||
self.assertEqual(play.name, "play factory")
|
||||
|
||||
def test_play_serializer(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
serializer = serializers.PlaySerializer(
|
||||
data={
|
||||
"name": "serializer",
|
||||
"status": "completed",
|
||||
"uuid": "5c5f67b9-e63c-6297-80da-000000000005",
|
||||
"playbook": playbook.id,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
play = serializer.save()
|
||||
play.refresh_from_db()
|
||||
self.assertEqual(play.name, "serializer")
|
||||
self.assertEqual(play.status, "completed")
|
||||
|
||||
def test_get_no_plays(self):
|
||||
request = self.client.get("/api/v1/plays")
|
||||
self.assertEqual(0, len(request.data["results"]))
|
||||
|
||||
def test_get_plays(self):
|
||||
play = factories.PlayFactory()
|
||||
request = self.client.get("/api/v1/plays")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(play.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_delete_play(self):
|
||||
play = factories.PlayFactory()
|
||||
self.assertEqual(1, models.Play.objects.all().count())
|
||||
request = self.client.delete("/api/v1/plays/%s" % play.id)
|
||||
self.assertEqual(204, request.status_code)
|
||||
self.assertEqual(0, models.Play.objects.all().count())
|
||||
|
||||
def test_create_play(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.Play.objects.count())
|
||||
request = self.client.post(
|
||||
"/api/v1/plays",
|
||||
{
|
||||
"name": "create",
|
||||
"status": "running",
|
||||
"uuid": "5c5f67b9-e63c-6297-80da-000000000005",
|
||||
"playbook": playbook.id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Play.objects.count())
|
||||
|
||||
def test_partial_update_play(self):
|
||||
play = factories.PlayFactory()
|
||||
self.assertNotEqual("update", play.name)
|
||||
request = self.client.patch("/api/v1/plays/%s" % play.id, {"name": "update"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
play_updated = models.Play.objects.get(id=play.id)
|
||||
self.assertEqual("update", play_updated.name)
|
||||
|
||||
def test_expired_play(self):
|
||||
play = factories.PlayFactory(status="running")
|
||||
self.assertEqual("running", play.status)
|
||||
|
||||
request = self.client.patch("/api/v1/plays/%s" % play.id, {"status": "expired"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
play_updated = models.Play.objects.get(id=play.id)
|
||||
self.assertEqual("expired", play_updated.status)
|
||||
|
||||
def test_get_play(self):
|
||||
play = factories.PlayFactory()
|
||||
request = self.client.get("/api/v1/plays/%s" % play.id)
|
||||
self.assertEqual(play.name, request.data["name"])
|
||||
|
||||
def test_get_play_by_playbook(self):
|
||||
play = factories.PlayFactory(name="play1")
|
||||
factories.PlayFactory(name="play2")
|
||||
request = self.client.get("/api/v1/plays?playbook=1")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(play.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_get_plays_by_name(self):
|
||||
# Create a playbook and two plays
|
||||
playbook = factories.PlaybookFactory()
|
||||
play = factories.PlayFactory(name="first_play", playbook=playbook)
|
||||
factories.TaskFactory(name="second_play", playbook=playbook)
|
||||
|
||||
# Query for the first play name and expect one result
|
||||
request = self.client.get("/api/v1/plays?name=%s" % play.name)
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(play.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_get_play_by_uuid(self):
|
||||
play = factories.PlayFactory(name="play1", uuid="6b838b6f-cfc7-4e11-a264-73df8683ee0e")
|
||||
factories.PlayFactory(name="play2")
|
||||
request = self.client.get("/api/v1/plays?uuid=6b838b6f-cfc7-4e11-a264-73df8683ee0e")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(play.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_get_play_duration(self):
|
||||
started = timezone.now()
|
||||
ended = started + datetime.timedelta(hours=1)
|
||||
play = factories.PlayFactory(started=started, ended=ended)
|
||||
request = self.client.get("/api/v1/plays/%s" % play.id)
|
||||
self.assertEqual(parse_duration(request.data["duration"]), ended - started)
|
||||
|
||||
def test_get_play_by_date(self):
|
||||
play = factories.PlayFactory()
|
||||
|
||||
past = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
negative_date_fields = ["created_before", "started_before", "updated_before"]
|
||||
positive_date_fields = ["created_after", "started_after", "updated_after"]
|
||||
|
||||
# Expect no play when searching before it was created
|
||||
for field in negative_date_fields:
|
||||
request = self.client.get("/api/v1/plays?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 0)
|
||||
|
||||
# Expect a play when searching after it was created
|
||||
for field in positive_date_fields:
|
||||
request = self.client.get("/api/v1/plays?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 1)
|
||||
self.assertEqual(request.data["results"][0]["id"], play.id)
|
||||
|
||||
def test_get_play_order(self):
|
||||
old_started = timezone.now() - datetime.timedelta(hours=12)
|
||||
old_ended = old_started + datetime.timedelta(minutes=30)
|
||||
old_play = factories.PlayFactory(started=old_started, ended=old_ended)
|
||||
new_started = timezone.now() - datetime.timedelta(hours=6)
|
||||
new_ended = new_started + datetime.timedelta(hours=1)
|
||||
new_play = factories.PlayFactory(started=new_started, ended=new_ended)
|
||||
|
||||
# Ensure we have two objects
|
||||
request = self.client.get("/api/v1/plays")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
order_fields = ["id", "created", "updated", "started", "ended", "duration"]
|
||||
# Ascending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/plays?order=%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], old_play.id)
|
||||
|
||||
# Descending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/plays?order=-%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], new_play.id)
|
||||
|
||||
def test_update_wrong_play_status(self):
|
||||
play = factories.PlayFactory()
|
||||
self.assertNotEqual("wrong", play.status)
|
||||
request = self.client.patch("/api/v1/plays/%s" % play.id, {"status": "wrong"})
|
||||
self.assertEqual(400, request.status_code)
|
||||
play_updated = models.Play.objects.get(id=play.id)
|
||||
self.assertNotEqual("wrong", play_updated.status)
|
||||
|
||||
def test_get_play_by_status(self):
|
||||
play = factories.PlayFactory(status="running")
|
||||
factories.PlayFactory(status="completed")
|
||||
factories.PlayFactory(status="unknown")
|
||||
|
||||
# Confirm we have three objects
|
||||
request = self.client.get("/api/v1/plays")
|
||||
self.assertEqual(3, len(request.data["results"]))
|
||||
|
||||
# Test single status
|
||||
request = self.client.get("/api/v1/plays?status=running")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(play.status, request.data["results"][0]["status"])
|
||||
|
||||
# Test multiple status
|
||||
request = self.client.get("/api/v1/plays?status=running&status=completed")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
@ -1,285 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_duration
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api import models, serializers
|
||||
from ara.api.tests import factories, utils
|
||||
|
||||
|
||||
class PlaybookTestCase(APITestCase):
|
||||
def test_playbook_factory(self):
|
||||
playbook = factories.PlaybookFactory(ansible_version="2.4.0")
|
||||
self.assertEqual(playbook.ansible_version, "2.4.0")
|
||||
|
||||
def test_playbook_serializer(self):
|
||||
serializer = serializers.PlaybookSerializer(
|
||||
data={
|
||||
"controller": "serializer",
|
||||
"name": "serializer-playbook",
|
||||
"ansible_version": "2.4.0",
|
||||
"path": "/path/playbook.yml",
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
playbook = serializer.save()
|
||||
playbook.refresh_from_db()
|
||||
self.assertEqual(playbook.controller, "serializer")
|
||||
self.assertEqual(playbook.name, "serializer-playbook")
|
||||
self.assertEqual(playbook.ansible_version, "2.4.0")
|
||||
self.assertEqual(playbook.status, "unknown")
|
||||
|
||||
def test_playbook_serializer_compress_arguments(self):
|
||||
serializer = serializers.PlaybookSerializer(
|
||||
data={"ansible_version": "2.4.0", "path": "/path/playbook.yml", "arguments": factories.PLAYBOOK_ARGUMENTS}
|
||||
)
|
||||
serializer.is_valid()
|
||||
playbook = serializer.save()
|
||||
playbook.refresh_from_db()
|
||||
self.assertEqual(playbook.arguments, utils.compressed_obj(factories.PLAYBOOK_ARGUMENTS))
|
||||
|
||||
def test_playbook_serializer_decompress_arguments(self):
|
||||
playbook = factories.PlaybookFactory(arguments=utils.compressed_obj(factories.PLAYBOOK_ARGUMENTS))
|
||||
serializer = serializers.PlaybookSerializer(instance=playbook)
|
||||
self.assertEqual(serializer.data["arguments"], factories.PLAYBOOK_ARGUMENTS)
|
||||
|
||||
def test_get_no_playbooks(self):
|
||||
request = self.client.get("/api/v1/playbooks")
|
||||
self.assertEqual(0, len(request.data["results"]))
|
||||
|
||||
def test_get_playbooks(self):
|
||||
expected_playbook = factories.PlaybookFactory()
|
||||
request = self.client.get("/api/v1/playbooks")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(1, request.data["count"])
|
||||
playbook = request.data["results"][0]
|
||||
self.assertEqual(playbook["ansible_version"], expected_playbook.ansible_version)
|
||||
|
||||
def test_delete_playbook(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
request = self.client.delete("/api/v1/playbooks/%s" % playbook.id)
|
||||
self.assertEqual(204, request.status_code)
|
||||
self.assertEqual(0, models.Playbook.objects.all().count())
|
||||
|
||||
def test_create_playbook(self):
|
||||
self.assertEqual(0, models.Playbook.objects.count())
|
||||
request = self.client.post(
|
||||
"/api/v1/playbooks", {"ansible_version": "2.4.0", "status": "running", "path": "/path/playbook.yml"}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Playbook.objects.count())
|
||||
self.assertEqual(request.data["status"], "running")
|
||||
|
||||
def test_create_playbook_with_labels(self):
|
||||
self.assertEqual(0, models.Playbook.objects.count())
|
||||
labels = ["test-label", "another-test-label"]
|
||||
request = self.client.post(
|
||||
"/api/v1/playbooks",
|
||||
{"ansible_version": "2.4.0", "status": "running", "path": "/path/playbook.yml", "labels": labels},
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Playbook.objects.count())
|
||||
self.assertEqual(request.data["status"], "running")
|
||||
self.assertEqual(sorted([label["name"] for label in request.data["labels"]]), sorted(labels))
|
||||
|
||||
def test_partial_update_playbook(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertNotEqual("completed", playbook.status)
|
||||
request = self.client.patch("/api/v1/playbooks/%s" % playbook.id, {"status": "completed"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
playbook_updated = models.Playbook.objects.get(id=playbook.id)
|
||||
self.assertEqual("completed", playbook_updated.status)
|
||||
|
||||
def test_update_wrong_playbook_status(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertNotEqual("wrong", playbook.status)
|
||||
request = self.client.patch("/api/v1/playbooks/%s" % playbook.id, {"status": "wrong"})
|
||||
self.assertEqual(400, request.status_code)
|
||||
playbook_updated = models.Playbook.objects.get(id=playbook.id)
|
||||
self.assertNotEqual("wrong", playbook_updated.status)
|
||||
|
||||
def test_expired_playbook(self):
|
||||
playbook = factories.PlaybookFactory(status="running")
|
||||
self.assertEqual("running", playbook.status)
|
||||
|
||||
request = self.client.patch("/api/v1/playbooks/%s" % playbook.id, {"status": "expired"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
playbook_updated = models.Playbook.objects.get(id=playbook.id)
|
||||
self.assertEqual("expired", playbook_updated.status)
|
||||
|
||||
def test_get_playbook(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
request = self.client.get("/api/v1/playbooks/%s" % playbook.id)
|
||||
self.assertEqual(playbook.ansible_version, request.data["ansible_version"])
|
||||
|
||||
def test_get_playbook_by_controller(self):
|
||||
playbook = factories.PlaybookFactory(name="playbook1", controller="controller-one")
|
||||
factories.PlaybookFactory(name="playbook2", controller="controller-two")
|
||||
|
||||
# Test exact match
|
||||
request = self.client.get("/api/v1/playbooks?controller=controller-one")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(playbook.name, request.data["results"][0]["name"])
|
||||
self.assertEqual(playbook.controller, request.data["results"][0]["controller"])
|
||||
|
||||
# Test partial match
|
||||
request = self.client.get("/api/v1/playbooks?controller=controller")
|
||||
self.assertEqual(len(request.data["results"]), 2)
|
||||
|
||||
def test_get_playbook_by_name(self):
|
||||
playbook = factories.PlaybookFactory(name="playbook1")
|
||||
factories.PlaybookFactory(name="playbook2")
|
||||
|
||||
# Test exact match
|
||||
request = self.client.get("/api/v1/playbooks?name=playbook1")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(playbook.name, request.data["results"][0]["name"])
|
||||
|
||||
# Test partial match
|
||||
request = self.client.get("/api/v1/playbooks?name=playbook")
|
||||
self.assertEqual(len(request.data["results"]), 2)
|
||||
|
||||
def test_get_playbook_by_ansible_version(self):
|
||||
playbook = factories.PlaybookFactory(name="playbook1", ansible_version="2.9.1")
|
||||
factories.PlaybookFactory(name="playbook2", ansible_version="2.8.2")
|
||||
|
||||
# Test exact match
|
||||
request = self.client.get("/api/v1/playbooks?ansible_version=2.9.1")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(playbook.name, request.data["results"][0]["name"])
|
||||
|
||||
# Test partial match
|
||||
request = self.client.get("/api/v1/playbooks?ansible_version=2.9")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(playbook.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_get_playbook_by_path(self):
|
||||
playbook = factories.PlaybookFactory(path="/root/playbook.yml")
|
||||
factories.PlaybookFactory(path="/home/playbook.yml")
|
||||
|
||||
# Test exact match
|
||||
request = self.client.get("/api/v1/playbooks?path=/root/playbook.yml")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(playbook.path, request.data["results"][0]["path"])
|
||||
|
||||
# Test partial match
|
||||
request = self.client.get("/api/v1/playbooks?path=playbook.yml")
|
||||
self.assertEqual(len(request.data["results"]), 2)
|
||||
|
||||
def test_patch_playbook_name(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
new_name = "foo"
|
||||
self.assertNotEqual(playbook.name, new_name)
|
||||
request = self.client.patch("/api/v1/playbooks/%s" % playbook.id, {"name": new_name})
|
||||
self.assertEqual(200, request.status_code)
|
||||
playbook_updated = models.Playbook.objects.get(id=playbook.id)
|
||||
self.assertEqual(playbook_updated.name, new_name)
|
||||
|
||||
def test_get_playbook_by_status(self):
|
||||
playbook = factories.PlaybookFactory(status="failed")
|
||||
factories.PlaybookFactory(status="completed")
|
||||
factories.PlaybookFactory(status="running")
|
||||
|
||||
# Confirm we have three objects
|
||||
request = self.client.get("/api/v1/playbooks")
|
||||
self.assertEqual(3, len(request.data["results"]))
|
||||
|
||||
# Test single status
|
||||
request = self.client.get("/api/v1/playbooks?status=failed")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(playbook.status, request.data["results"][0]["status"])
|
||||
|
||||
# Test multiple status
|
||||
request = self.client.get("/api/v1/playbooks?status=failed&status=completed")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
def test_get_playbook_duration(self):
|
||||
started = timezone.now()
|
||||
ended = started + datetime.timedelta(hours=1)
|
||||
playbook = factories.PlaybookFactory(started=started, ended=ended)
|
||||
request = self.client.get("/api/v1/playbooks/%s" % playbook.id)
|
||||
self.assertEqual(parse_duration(request.data["duration"]), ended - started)
|
||||
|
||||
def test_get_playbook_by_date(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
|
||||
past = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
negative_date_fields = ["created_before", "started_before", "updated_before"]
|
||||
positive_date_fields = ["created_after", "started_after", "updated_after"]
|
||||
|
||||
# Expect no playbook when searching before it was created
|
||||
for field in negative_date_fields:
|
||||
request = self.client.get("/api/v1/playbooks?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 0)
|
||||
|
||||
# Expect a playbook when searching after it was created
|
||||
for field in positive_date_fields:
|
||||
request = self.client.get("/api/v1/playbooks?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 1)
|
||||
self.assertEqual(request.data["results"][0]["id"], playbook.id)
|
||||
|
||||
def test_get_playbook_order(self):
|
||||
old_started = timezone.now() - datetime.timedelta(hours=12)
|
||||
old_ended = old_started + datetime.timedelta(minutes=30)
|
||||
old_playbook = factories.PlaybookFactory(started=old_started, ended=old_ended)
|
||||
new_started = timezone.now() - datetime.timedelta(hours=6)
|
||||
new_ended = new_started + datetime.timedelta(hours=1)
|
||||
new_playbook = factories.PlaybookFactory(started=new_started, ended=new_ended)
|
||||
|
||||
# Ensure we have two objects
|
||||
request = self.client.get("/api/v1/playbooks")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
order_fields = ["id", "created", "updated", "started", "ended", "duration"]
|
||||
# Ascending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/playbooks?order=%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], old_playbook.id)
|
||||
|
||||
# Descending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/playbooks?order=-%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], new_playbook.id)
|
||||
|
||||
def test_patch_playbook_labels(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
labels = ["test-label", "another-test-label"]
|
||||
self.assertNotEqual(playbook.labels, labels)
|
||||
request = self.client.patch("/api/v1/playbooks/%s" % playbook.id, {"labels": labels})
|
||||
self.assertEqual(200, request.status_code)
|
||||
playbook_updated = models.Playbook.objects.get(id=playbook.id)
|
||||
self.assertEqual([label.name for label in playbook_updated.labels.all()], labels)
|
||||
|
||||
def test_get_playbook_by_label(self):
|
||||
# Create two playbooks, one with labels and one without
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.client.patch("/api/v1/playbooks/%s" % playbook.id, {"labels": ["test-label"]})
|
||||
factories.PlaybookFactory()
|
||||
|
||||
# Ensure we have two objects when searching without labels
|
||||
request = self.client.get("/api/v1/playbooks")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
# Search with label and ensure we have the right one
|
||||
request = self.client.get("/api/v1/playbooks?label=%s" % "test-label")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(request.data["results"][0]["labels"][0]["name"], "test-label")
|
@ -1,153 +0,0 @@
|
||||
import datetime
|
||||
from unittest import skip
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.test import LiveServerTestCase, TestCase, override_settings
|
||||
|
||||
from ara.api import models
|
||||
from ara.api.tests import factories
|
||||
|
||||
|
||||
class LogCheckerMixin(object):
|
||||
def run_prune_command(self, *args, **opts):
|
||||
# the command uses logging instead of prints so we need to use assertLogs
|
||||
# to retrieve and test the output
|
||||
with self.assertLogs("ara.api.management.commands.prune", "INFO") as logs:
|
||||
call_command("prune", *args, **opts)
|
||||
return logs.output
|
||||
|
||||
|
||||
class PruneTestCase(TestCase, LogCheckerMixin):
|
||||
@skip("TODO: Why aren't logs captured properly for this test ?")
|
||||
def test_prune_without_playbooks_and_confirm(self):
|
||||
output = self.run_prune_command()
|
||||
self.assertIn(
|
||||
"INFO:ara.api.management.commands.prune:--confirm was not specified, no playbooks will be deleted", output
|
||||
)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:Found 0 playbooks matching query", output)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:0 playbooks deleted", output)
|
||||
|
||||
@skip("TODO: Why aren't logs captured properly for this test ?")
|
||||
def test_prune_without_playbooks(self):
|
||||
args = ["--confirm"]
|
||||
output = self.run_prune_command(*args)
|
||||
self.assertNotIn(
|
||||
"INFO:ara.api.management.commands.prune:--confirm was not specified, no playbooks will be deleted", output
|
||||
)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:Found 0 playbooks matching query", output)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:0 playbooks deleted", output)
|
||||
|
||||
|
||||
class PruneCmdTestCase(LiveServerTestCase, LogCheckerMixin):
|
||||
@skip("TODO: Why aren't logs captured properly for this test ?")
|
||||
def test_prune_with_no_matching_playbook(self):
|
||||
# Create a playbook with start date as of now
|
||||
factories.PlaybookFactory()
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
|
||||
args = ["--confirm"]
|
||||
output = self.run_prune_command(*args)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:Found 0 playbooks matching query", output)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:0 playbooks deleted", output)
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
|
||||
@skip("TODO: Why aren't logs captured properly for this test ?")
|
||||
def test_prune_with_matching_playbook(self):
|
||||
# Create a playbook with an old start date
|
||||
old_timestamp = datetime.datetime.now() - datetime.timedelta(days=60)
|
||||
factories.PlaybookFactory(started=old_timestamp)
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
|
||||
args = ["--confirm"]
|
||||
output = self.run_prune_command(*args)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:Found 1 playbooks matching query", output)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:1 playbooks deleted", output)
|
||||
self.assertEqual(0, models.Playbook.objects.all().count())
|
||||
|
||||
def test_prune_with_no_matching_playbook_with_http_client(self):
|
||||
# Create a playbook with start date as of now
|
||||
factories.PlaybookFactory()
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
|
||||
args = ["--confirm", "--client", "http", "--endpoint", self.live_server_url]
|
||||
output = self.run_prune_command(*args)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:Found 0 playbooks matching query", output)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:0 playbooks deleted", output)
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
|
||||
def test_prune_with_matching_playbook_with_http_client(self):
|
||||
# Create a playbook with an old start date
|
||||
old_timestamp = datetime.datetime.now() - datetime.timedelta(days=60)
|
||||
factories.PlaybookFactory(started=old_timestamp)
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
|
||||
args = ["--confirm", "--client", "http", "--endpoint", self.live_server_url]
|
||||
output = self.run_prune_command(*args)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:Found 1 playbooks matching query", output)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:1 playbooks deleted", output)
|
||||
self.assertEqual(0, models.Playbook.objects.all().count())
|
||||
|
||||
@override_settings(READ_LOGIN_REQUIRED=True, WRITE_LOGIN_REQUIRED=True)
|
||||
def test_prune_without_authenticated_http_client(self):
|
||||
args = ["--confirm", "--client", "http", "--endpoint", self.live_server_url]
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_prune_command(*args)
|
||||
|
||||
@override_settings(READ_LOGIN_REQUIRED=True, WRITE_LOGIN_REQUIRED=True)
|
||||
def test_prune_with_authenticated_http_client(self):
|
||||
# Create a user
|
||||
self.user = User.objects.create_superuser("prune", "prune@example.org", "password")
|
||||
|
||||
# Create a playbook with an old start date
|
||||
old_timestamp = datetime.datetime.now() - datetime.timedelta(days=60)
|
||||
factories.PlaybookFactory(started=old_timestamp)
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
|
||||
args = [
|
||||
"--confirm",
|
||||
"--client",
|
||||
"http",
|
||||
"--endpoint",
|
||||
self.live_server_url,
|
||||
"--username",
|
||||
"prune",
|
||||
"--password",
|
||||
"password",
|
||||
]
|
||||
output = self.run_prune_command(*args)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:Found 1 playbooks matching query", output)
|
||||
self.assertIn("INFO:ara.api.management.commands.prune:1 playbooks deleted", output)
|
||||
self.assertEqual(0, models.Playbook.objects.all().count())
|
||||
|
||||
@override_settings(READ_LOGIN_REQUIRED=True, WRITE_LOGIN_REQUIRED=True)
|
||||
def test_prune_with_bad_authentication_http_client(self):
|
||||
# Create a user
|
||||
self.user = User.objects.create_superuser("prune", "prune@example.org", "password")
|
||||
|
||||
# Create a playbook with an old start date
|
||||
old_timestamp = datetime.datetime.now() - datetime.timedelta(days=60)
|
||||
factories.PlaybookFactory(started=old_timestamp)
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
||||
|
||||
# Set up arguments with a wrong password
|
||||
args = [
|
||||
"--confirm",
|
||||
"--client",
|
||||
"http",
|
||||
"--endpoint",
|
||||
self.live_server_url,
|
||||
"--username",
|
||||
"prune",
|
||||
"--password",
|
||||
"somethingelse",
|
||||
]
|
||||
|
||||
with self.assertRaises(SystemExit):
|
||||
self.run_prune_command(*args)
|
||||
# TODO: the assertRaises prevents us from looking at the output
|
||||
# output = run_prune_command(*args)
|
||||
# self.assertIn("Client failed to retrieve results, see logs for ara.clients.offline or ara.clients.http.", output) # noqa
|
||||
|
||||
# Nothing should have been deleted because the command failed
|
||||
self.assertEqual(1, models.Playbook.objects.all().count())
|
@ -1,187 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api import models, serializers
|
||||
from ara.api.tests import factories, utils
|
||||
|
||||
|
||||
class RecordTestCase(APITestCase):
|
||||
def test_record_factory(self):
|
||||
record = factories.RecordFactory(key="test")
|
||||
self.assertEqual(record.key, "test")
|
||||
|
||||
def test_record_serializer(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
serializer = serializers.RecordSerializer(
|
||||
data={"key": "test", "value": factories.RECORD_LIST, "type": "list", "playbook": playbook.id}
|
||||
)
|
||||
serializer.is_valid()
|
||||
record = serializer.save()
|
||||
record.refresh_from_db()
|
||||
self.assertEqual(record.key, "test")
|
||||
self.assertEqual(record.value, utils.compressed_obj(factories.RECORD_LIST))
|
||||
self.assertEqual(record.type, "list")
|
||||
|
||||
def test_get_no_records(self):
|
||||
request = self.client.get("/api/v1/records")
|
||||
self.assertEqual(0, len(request.data["results"]))
|
||||
|
||||
def test_get_record(self):
|
||||
record = factories.RecordFactory()
|
||||
request = self.client.get("/api/v1/records")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(record.key, request.data["results"][0]["key"])
|
||||
|
||||
def test_delete_record(self):
|
||||
record = factories.RecordFactory()
|
||||
self.assertEqual(1, models.Record.objects.all().count())
|
||||
request = self.client.delete("/api/v1/records/%s" % record.id)
|
||||
self.assertEqual(204, request.status_code)
|
||||
self.assertEqual(0, models.Record.objects.all().count())
|
||||
|
||||
def test_create_text_record(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.Record.objects.count())
|
||||
request = self.client.post(
|
||||
"/api/v1/records", {"key": "test", "value": "value", "type": "text", "playbook": playbook.id}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Record.objects.count())
|
||||
|
||||
def test_create_list_record(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.Record.objects.count())
|
||||
test_list = factories.RECORD_LIST
|
||||
request = self.client.post(
|
||||
"/api/v1/records", {"key": "listrecord", "value": test_list, "type": "list", "playbook": playbook.id}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Record.objects.count())
|
||||
self.assertEqual(test_list, request.data["value"])
|
||||
|
||||
def test_create_dict_record(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.Record.objects.count())
|
||||
test_dict = {"a": "dictionary"}
|
||||
request = self.client.post(
|
||||
"/api/v1/records", {"key": "dictrecord", "value": test_dict, "type": "dict", "playbook": playbook.id}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Record.objects.count())
|
||||
self.assertEqual(test_dict, request.data["value"])
|
||||
|
||||
def test_create_json_record(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.Record.objects.count())
|
||||
test_json = '{"a": "dictionary"}'
|
||||
request = self.client.post(
|
||||
"/api/v1/records", {"key": "dictrecord", "value": test_json, "type": "json", "playbook": playbook.id}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Record.objects.count())
|
||||
self.assertEqual(test_json, request.data["value"])
|
||||
|
||||
def test_create_url_record(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
self.assertEqual(0, models.Record.objects.count())
|
||||
test_url = "https://ara.recordsansible.org"
|
||||
request = self.client.post(
|
||||
"/api/v1/records", {"key": "dictrecord", "value": test_url, "type": "url", "playbook": playbook.id}
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Record.objects.count())
|
||||
self.assertEqual(test_url, request.data["value"])
|
||||
|
||||
def test_partial_update_record(self):
|
||||
record = factories.RecordFactory()
|
||||
self.assertNotEqual("update", record.key)
|
||||
request = self.client.patch("/api/v1/records/%s" % record.id, {"key": "update"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
record_updated = models.Record.objects.get(id=record.id)
|
||||
self.assertEqual("update", record_updated.key)
|
||||
|
||||
def test_get_records_by_playbook(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
record = factories.RecordFactory(playbook=playbook, key="by_playbook")
|
||||
factories.RecordFactory(key="another_record")
|
||||
request = self.client.get("/api/v1/records?playbook=%s" % playbook.id)
|
||||
self.assertEqual(2, models.Record.objects.all().count())
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(record.key, request.data["results"][0]["key"])
|
||||
self.assertEqual(record.playbook.id, request.data["results"][0]["playbook"])
|
||||
|
||||
def test_get_records_by_key(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
record = factories.RecordFactory(playbook=playbook, key="by_key")
|
||||
factories.RecordFactory(key="another_record")
|
||||
request = self.client.get("/api/v1/records?key=%s" % record.key)
|
||||
self.assertEqual(2, models.Record.objects.all().count())
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(record.key, request.data["results"][0]["key"])
|
||||
self.assertEqual(record.playbook.id, request.data["results"][0]["playbook"])
|
||||
|
||||
def test_get_records_by_playbook_and_key(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
record = factories.RecordFactory(playbook=playbook, key="by_playbook_and_key")
|
||||
factories.RecordFactory(playbook=playbook, key="another_record_in_playbook")
|
||||
factories.RecordFactory(key="another_record_in_another_playbook")
|
||||
request = self.client.get("/api/v1/records?playbook=%s&key=%s" % (playbook.id, record.key))
|
||||
self.assertEqual(3, models.Record.objects.all().count())
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(record.key, request.data["results"][0]["key"])
|
||||
self.assertEqual(record.playbook.id, request.data["results"][0]["playbook"])
|
||||
|
||||
def test_get_record_by_date(self):
|
||||
record = factories.RecordFactory()
|
||||
|
||||
past = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
negative_date_fields = ["created_before", "updated_before"]
|
||||
positive_date_fields = ["created_after", "updated_after"]
|
||||
|
||||
# Expect no record when searching before it was created
|
||||
for field in negative_date_fields:
|
||||
request = self.client.get("/api/v1/records?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 0)
|
||||
|
||||
# Expect a record when searching after it was created
|
||||
for field in positive_date_fields:
|
||||
request = self.client.get("/api/v1/records?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 1)
|
||||
self.assertEqual(request.data["results"][0]["id"], record.id)
|
||||
|
||||
def test_get_record_order(self):
|
||||
first_record = factories.RecordFactory(key="alpha")
|
||||
second_record = factories.RecordFactory(key="beta")
|
||||
|
||||
# Ensure we have two objects
|
||||
request = self.client.get("/api/v1/records")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
order_fields = ["id", "created", "updated", "key"]
|
||||
# Ascending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/records?order=%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], first_record.id)
|
||||
|
||||
# Descending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/records?order=-%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], second_record.id)
|
@ -1,286 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_duration
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api import models, serializers
|
||||
from ara.api.tests import factories, utils
|
||||
|
||||
|
||||
class ResultTestCase(APITestCase):
|
||||
def test_result_factory(self):
|
||||
result = factories.ResultFactory(status="failed")
|
||||
self.assertEqual(result.status, "failed")
|
||||
|
||||
def test_result_serializer(self):
|
||||
host = factories.HostFactory()
|
||||
task = factories.TaskFactory()
|
||||
serializer = serializers.ResultSerializer(
|
||||
data={
|
||||
"status": "skipped",
|
||||
"host": host.id,
|
||||
"task": task.id,
|
||||
"play": task.play.id,
|
||||
"playbook": task.playbook.id,
|
||||
"changed": False,
|
||||
"ignore_errors": False,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
result = serializer.save()
|
||||
result.refresh_from_db()
|
||||
self.assertEqual(result.status, "skipped")
|
||||
self.assertEqual(result.changed, False)
|
||||
self.assertEqual(result.ignore_errors, False)
|
||||
self.assertEqual(result.host.id, host.id)
|
||||
self.assertEqual(result.task.id, task.id)
|
||||
|
||||
def test_result_serializer_compress_content(self):
|
||||
host = factories.HostFactory()
|
||||
task = factories.TaskFactory()
|
||||
serializer = serializers.ResultSerializer(
|
||||
data={
|
||||
"content": factories.RESULT_CONTENTS,
|
||||
"status": "ok",
|
||||
"host": host.id,
|
||||
"task": task.id,
|
||||
"play": task.play.id,
|
||||
"playbook": task.playbook.id,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
result = serializer.save()
|
||||
result.refresh_from_db()
|
||||
self.assertEqual(result.content, utils.compressed_obj(factories.RESULT_CONTENTS))
|
||||
|
||||
def test_result_serializer_decompress_content(self):
|
||||
result = factories.ResultFactory(content=utils.compressed_obj(factories.RESULT_CONTENTS))
|
||||
serializer = serializers.ResultSerializer(instance=result)
|
||||
self.assertEqual(serializer.data["content"], factories.RESULT_CONTENTS)
|
||||
|
||||
def test_get_no_results(self):
|
||||
request = self.client.get("/api/v1/results")
|
||||
self.assertEqual(0, len(request.data["results"]))
|
||||
|
||||
def test_get_results(self):
|
||||
result = factories.ResultFactory()
|
||||
request = self.client.get("/api/v1/results")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(result.status, request.data["results"][0]["status"])
|
||||
|
||||
def test_delete_result(self):
|
||||
result = factories.ResultFactory()
|
||||
self.assertEqual(1, models.Result.objects.all().count())
|
||||
request = self.client.delete("/api/v1/results/%s" % result.id)
|
||||
self.assertEqual(204, request.status_code)
|
||||
self.assertEqual(0, models.Result.objects.all().count())
|
||||
|
||||
def test_create_result(self):
|
||||
host = factories.HostFactory()
|
||||
task = factories.TaskFactory()
|
||||
self.assertEqual(0, models.Result.objects.count())
|
||||
request = self.client.post(
|
||||
"/api/v1/results",
|
||||
{
|
||||
"content": factories.RESULT_CONTENTS,
|
||||
"status": "ok",
|
||||
"host": host.id,
|
||||
"task": task.id,
|
||||
"play": task.play.id,
|
||||
"playbook": task.playbook.id,
|
||||
"changed": True,
|
||||
"ignore_errors": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(request.data["changed"], True)
|
||||
self.assertEqual(request.data["ignore_errors"], False)
|
||||
self.assertEqual(1, models.Result.objects.count())
|
||||
|
||||
def test_partial_update_result(self):
|
||||
result = factories.ResultFactory()
|
||||
self.assertNotEqual("unreachable", result.status)
|
||||
request = self.client.patch("/api/v1/results/%s" % result.id, {"status": "unreachable"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
result_updated = models.Result.objects.get(id=result.id)
|
||||
self.assertEqual("unreachable", result_updated.status)
|
||||
|
||||
def test_get_result(self):
|
||||
result = factories.ResultFactory()
|
||||
request = self.client.get("/api/v1/results/%s" % result.id)
|
||||
self.assertEqual(result.status, request.data["status"])
|
||||
|
||||
def test_get_result_by_association(self):
|
||||
# Create two results in necessarily two different playbooks with different children:
|
||||
# playbook -> play -> task -> result <- host
|
||||
first_result = factories.ResultFactory()
|
||||
second_result = factories.ResultFactory()
|
||||
|
||||
# the fields with the association ids
|
||||
associations = ["playbook", "play", "task", "host"]
|
||||
|
||||
# Validate that we somehow didn't wind up with the same association ids
|
||||
for association in associations:
|
||||
first = getattr(first_result, association)
|
||||
second = getattr(second_result, association)
|
||||
self.assertNotEqual(first.id, second.id)
|
||||
|
||||
# In other words, there must be two distinct results
|
||||
request = self.client.get("/api/v1/results")
|
||||
self.assertEqual(2, request.data["count"])
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
# Searching for the first_result associations should only yield one result
|
||||
for association in associations:
|
||||
assoc_id = getattr(first_result, association).id
|
||||
results = self.client.get("/api/v1/results?%s=%s" % (association, assoc_id))
|
||||
self.assertEqual(1, results.data["count"])
|
||||
self.assertEqual(1, len(results.data["results"]))
|
||||
self.assertEqual(assoc_id, results.data["results"][0][association])
|
||||
|
||||
def test_get_result_by_statuses(self):
|
||||
failed_result = factories.ResultFactory(status="failed")
|
||||
skipped_result = factories.ResultFactory(status="skipped")
|
||||
factories.ResultFactory(status="ok")
|
||||
results = self.client.get("/api/v1/results").data["results"]
|
||||
self.assertEqual(3, len(results))
|
||||
|
||||
results = self.client.get("/api/v1/results?status=failed").data["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual(failed_result.status, results[0]["status"])
|
||||
|
||||
results = self.client.get("/api/v1/results?status=skipped").data["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual(skipped_result.status, results[0]["status"])
|
||||
|
||||
results = self.client.get("/api/v1/results?status=failed&status=skipped").data["results"]
|
||||
self.assertEqual(2, len(results))
|
||||
self.assertEqual(failed_result.status, results[1]["status"])
|
||||
self.assertEqual(skipped_result.status, results[0]["status"])
|
||||
|
||||
def test_result_status_serializer(self):
|
||||
ok = factories.ResultFactory(status="ok")
|
||||
result = self.client.get("/api/v1/results/%s" % ok.id)
|
||||
self.assertEqual(result.data["status"], "ok")
|
||||
|
||||
changed = factories.ResultFactory(status="ok", changed=True)
|
||||
result = self.client.get("/api/v1/results/%s" % changed.id)
|
||||
self.assertEqual(result.data["status"], "changed")
|
||||
|
||||
failed = factories.ResultFactory(status="failed")
|
||||
result = self.client.get("/api/v1/results/%s" % failed.id)
|
||||
self.assertEqual(result.data["status"], "failed")
|
||||
|
||||
ignored = factories.ResultFactory(status="failed", ignore_errors=True)
|
||||
result = self.client.get("/api/v1/results/%s" % ignored.id)
|
||||
self.assertEqual(result.data["status"], "ignored")
|
||||
|
||||
skipped = factories.ResultFactory(status="skipped")
|
||||
result = self.client.get("/api/v1/results/%s" % skipped.id)
|
||||
self.assertEqual(result.data["status"], "skipped")
|
||||
|
||||
unreachable = factories.ResultFactory(status="unreachable")
|
||||
result = self.client.get("/api/v1/results/%s" % unreachable.id)
|
||||
self.assertEqual(result.data["status"], "unreachable")
|
||||
|
||||
def test_get_result_with_ignore_errors(self):
|
||||
failed = factories.ResultFactory(status="failed", ignore_errors=False)
|
||||
ignored = factories.ResultFactory(status="failed", ignore_errors=True)
|
||||
|
||||
# Searching for failed should return both
|
||||
results = self.client.get("/api/v1/results?status=failed").data["results"]
|
||||
self.assertEqual(2, len(results))
|
||||
|
||||
# Searching for failed with ignore_errors=True should only return the ignored result
|
||||
results = self.client.get("/api/v1/results?status=failed&ignore_errors=true").data["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual(ignored.id, results[0]["id"])
|
||||
|
||||
# Searching for failed with ignore_errors=False should only return the failed result
|
||||
results = self.client.get("/api/v1/results?status=failed&ignore_errors=false").data["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual(failed.id, results[0]["id"])
|
||||
|
||||
def test_get_result_duration(self):
|
||||
started = timezone.now()
|
||||
ended = started + datetime.timedelta(hours=1)
|
||||
result = factories.ResultFactory(started=started, ended=ended)
|
||||
request = self.client.get("/api/v1/results/%s" % result.id)
|
||||
self.assertEqual(parse_duration(request.data["duration"]), ended - started)
|
||||
|
||||
def test_get_result_by_date(self):
|
||||
result = factories.ResultFactory()
|
||||
|
||||
past = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
negative_date_fields = ["created_before", "started_before", "updated_before"]
|
||||
positive_date_fields = ["created_after", "started_after", "updated_after"]
|
||||
|
||||
# Expect no result when searching before it was created
|
||||
for field in negative_date_fields:
|
||||
request = self.client.get("/api/v1/results?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 0)
|
||||
|
||||
# Expect a result when searching after it was created
|
||||
for field in positive_date_fields:
|
||||
request = self.client.get("/api/v1/results?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 1)
|
||||
self.assertEqual(request.data["results"][0]["id"], result.id)
|
||||
|
||||
def test_get_result_order(self):
|
||||
old_started = timezone.now() - datetime.timedelta(hours=12)
|
||||
old_ended = old_started + datetime.timedelta(minutes=30)
|
||||
old_result = factories.ResultFactory(started=old_started, ended=old_ended)
|
||||
new_started = timezone.now() - datetime.timedelta(hours=6)
|
||||
new_ended = new_started + datetime.timedelta(hours=1)
|
||||
new_result = factories.ResultFactory(started=new_started, ended=new_ended)
|
||||
|
||||
# Ensure we have two objects
|
||||
request = self.client.get("/api/v1/results")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
order_fields = ["id", "created", "updated", "started", "ended", "duration"]
|
||||
# Ascending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/results?order=%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], old_result.id)
|
||||
|
||||
# Descending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/results?order=-%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], new_result.id)
|
||||
|
||||
def test_get_changed_results(self):
|
||||
changed_result = factories.ResultFactory(changed=True)
|
||||
unchanged_result = factories.ResultFactory(changed=False)
|
||||
|
||||
# Assert two results
|
||||
results = self.client.get("/api/v1/results").data["results"]
|
||||
self.assertEqual(2, len(results))
|
||||
|
||||
# Assert one changed
|
||||
results = self.client.get("/api/v1/results?changed=true").data["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual(results[0]["id"], changed_result.id)
|
||||
|
||||
# Assert one unchanged
|
||||
results = self.client.get("/api/v1/results?changed=false").data["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual(results[0]["id"], unchanged_result.id)
|
@ -1,263 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_duration
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from ara.api import models, serializers
|
||||
from ara.api.tests import factories, utils
|
||||
|
||||
|
||||
class TaskTestCase(APITestCase):
|
||||
def test_task_factory(self):
|
||||
task = factories.TaskFactory(name="factory")
|
||||
self.assertEqual(task.name, "factory")
|
||||
|
||||
def test_task_serializer(self):
|
||||
play = factories.PlayFactory()
|
||||
file = factories.FileFactory()
|
||||
serializer = serializers.TaskSerializer(
|
||||
data={
|
||||
"name": "serializer",
|
||||
"action": "test",
|
||||
"lineno": 2,
|
||||
"status": "completed",
|
||||
"handler": False,
|
||||
"play": play.id,
|
||||
"file": file.id,
|
||||
"playbook": play.playbook.id,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
task = serializer.save()
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.name, "serializer")
|
||||
self.assertEqual(task.status, "completed")
|
||||
|
||||
def test_task_serializer_compress_tags(self):
|
||||
play = factories.PlayFactory()
|
||||
file = factories.FileFactory()
|
||||
serializer = serializers.TaskSerializer(
|
||||
data={
|
||||
"name": "compress",
|
||||
"action": "test",
|
||||
"lineno": 2,
|
||||
"status": "running",
|
||||
"handler": False,
|
||||
"play": play.id,
|
||||
"file": file.id,
|
||||
"tags": factories.TASK_TAGS,
|
||||
"playbook": play.playbook.id,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
task = serializer.save()
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.tags, utils.compressed_obj(factories.TASK_TAGS))
|
||||
|
||||
def test_task_serializer_decompress_tags(self):
|
||||
task = factories.TaskFactory(tags=utils.compressed_obj(factories.TASK_TAGS))
|
||||
serializer = serializers.TaskSerializer(instance=task)
|
||||
self.assertEqual(serializer.data["tags"], factories.TASK_TAGS)
|
||||
|
||||
def test_get_no_tasks(self):
|
||||
request = self.client.get("/api/v1/tasks")
|
||||
self.assertEqual(0, len(request.data["results"]))
|
||||
|
||||
def test_get_tasks(self):
|
||||
task = factories.TaskFactory()
|
||||
request = self.client.get("/api/v1/tasks")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(task.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_delete_task(self):
|
||||
task = factories.TaskFactory()
|
||||
self.assertEqual(1, models.Task.objects.all().count())
|
||||
request = self.client.delete("/api/v1/tasks/%s" % task.id)
|
||||
self.assertEqual(204, request.status_code)
|
||||
self.assertEqual(0, models.Task.objects.all().count())
|
||||
|
||||
def test_create_task(self):
|
||||
play = factories.PlayFactory()
|
||||
file = factories.FileFactory()
|
||||
self.assertEqual(0, models.Task.objects.count())
|
||||
request = self.client.post(
|
||||
"/api/v1/tasks",
|
||||
{
|
||||
"name": "create",
|
||||
"action": "test",
|
||||
"lineno": 2,
|
||||
"handler": False,
|
||||
"status": "running",
|
||||
"play": play.id,
|
||||
"file": file.id,
|
||||
"playbook": play.playbook.id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, request.status_code)
|
||||
self.assertEqual(1, models.Task.objects.count())
|
||||
|
||||
def test_partial_update_task(self):
|
||||
task = factories.TaskFactory()
|
||||
self.assertNotEqual("update", task.name)
|
||||
request = self.client.patch("/api/v1/tasks/%s" % task.id, {"name": "update"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
task_updated = models.Task.objects.get(id=task.id)
|
||||
self.assertEqual("update", task_updated.name)
|
||||
|
||||
def test_expired_task(self):
|
||||
task = factories.TaskFactory(status="running")
|
||||
self.assertEqual("running", task.status)
|
||||
|
||||
request = self.client.patch("/api/v1/tasks/%s" % task.id, {"status": "expired"})
|
||||
self.assertEqual(200, request.status_code)
|
||||
task_updated = models.Task.objects.get(id=task.id)
|
||||
self.assertEqual("expired", task_updated.status)
|
||||
|
||||
def test_get_task(self):
|
||||
task = factories.TaskFactory()
|
||||
request = self.client.get("/api/v1/tasks/%s" % task.id)
|
||||
self.assertEqual(task.name, request.data["name"])
|
||||
|
||||
def test_get_tasks_by_playbook(self):
|
||||
playbook = factories.PlaybookFactory()
|
||||
task = factories.TaskFactory(name="task1", playbook=playbook)
|
||||
factories.TaskFactory(name="task2", playbook=playbook)
|
||||
request = self.client.get("/api/v1/tasks?playbook=%s" % playbook.id)
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
self.assertEqual(task.name, request.data["results"][1]["name"])
|
||||
self.assertEqual("task2", request.data["results"][0]["name"])
|
||||
|
||||
def test_get_tasks_by_name(self):
|
||||
# Create a playbook and two tasks
|
||||
playbook = factories.PlaybookFactory()
|
||||
task = factories.TaskFactory(name="task1", playbook=playbook)
|
||||
factories.TaskFactory(name="task2", playbook=playbook)
|
||||
|
||||
# Query for the first task name and expect one result
|
||||
request = self.client.get("/api/v1/tasks?name=%s" % task.name)
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(task.name, request.data["results"][0]["name"])
|
||||
|
||||
def test_get_task_duration(self):
|
||||
started = timezone.now()
|
||||
ended = started + datetime.timedelta(hours=1)
|
||||
task = factories.TaskFactory(started=started, ended=ended)
|
||||
request = self.client.get("/api/v1/tasks/%s" % task.id)
|
||||
self.assertEqual(parse_duration(request.data["duration"]), ended - started)
|
||||
|
||||
def test_get_task_by_date(self):
|
||||
task = factories.TaskFactory()
|
||||
|
||||
past = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
negative_date_fields = ["created_before", "started_before", "updated_before"]
|
||||
positive_date_fields = ["created_after", "started_after", "updated_after"]
|
||||
|
||||
# Expect no task when searching before it was created
|
||||
for field in negative_date_fields:
|
||||
request = self.client.get("/api/v1/tasks?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 0)
|
||||
|
||||
# Expect a task when searching after it was created
|
||||
for field in positive_date_fields:
|
||||
request = self.client.get("/api/v1/tasks?%s=%s" % (field, past.isoformat()))
|
||||
self.assertEqual(request.data["count"], 1)
|
||||
self.assertEqual(request.data["results"][0]["id"], task.id)
|
||||
|
||||
def test_get_task_order(self):
|
||||
old_started = timezone.now() - datetime.timedelta(hours=12)
|
||||
old_ended = old_started + datetime.timedelta(minutes=30)
|
||||
old_task = factories.TaskFactory(started=old_started, ended=old_ended)
|
||||
new_started = timezone.now() - datetime.timedelta(hours=6)
|
||||
new_ended = new_started + datetime.timedelta(hours=1)
|
||||
new_task = factories.TaskFactory(started=new_started, ended=new_ended)
|
||||
|
||||
# Ensure we have two objects
|
||||
request = self.client.get("/api/v1/tasks")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
order_fields = ["id", "created", "updated", "started", "ended", "duration"]
|
||||
# Ascending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/tasks?order=%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], old_task.id)
|
||||
|
||||
# Descending order
|
||||
for field in order_fields:
|
||||
request = self.client.get("/api/v1/tasks?order=-%s" % field)
|
||||
self.assertEqual(request.data["results"][0]["id"], new_task.id)
|
||||
|
||||
def test_update_wrong_task_status(self):
|
||||
task = factories.TaskFactory()
|
||||
self.assertNotEqual("wrong", task.status)
|
||||
request = self.client.patch("/api/v1/tasks/%s" % task.id, {"status": "wrong"})
|
||||
self.assertEqual(400, request.status_code)
|
||||
task_updated = models.Task.objects.get(id=task.id)
|
||||
self.assertNotEqual("wrong", task_updated.status)
|
||||
|
||||
def test_get_task_by_status(self):
|
||||
task = factories.TaskFactory(status="running")
|
||||
factories.TaskFactory(status="completed")
|
||||
factories.TaskFactory(status="unknown")
|
||||
|
||||
# Confirm we have three objects
|
||||
request = self.client.get("/api/v1/tasks")
|
||||
self.assertEqual(3, len(request.data["results"]))
|
||||
|
||||
# Test single status
|
||||
request = self.client.get("/api/v1/tasks?status=running")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(task.status, request.data["results"][0]["status"])
|
||||
|
||||
# Test multiple status
|
||||
request = self.client.get("/api/v1/tasks?status=running&status=completed")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
def test_get_task_by_action(self):
|
||||
task = factories.TaskFactory(action="debug")
|
||||
factories.TaskFactory(action="setup")
|
||||
|
||||
# Confirm we have two objects
|
||||
request = self.client.get("/api/v1/tasks")
|
||||
self.assertEqual(2, len(request.data["results"]))
|
||||
|
||||
# Expect the correct single result when searching
|
||||
request = self.client.get("/api/v1/tasks?action=debug")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(task.id, request.data["results"][0]["id"])
|
||||
self.assertEqual(task.action, request.data["results"][0]["action"])
|
||||
|
||||
def test_get_task_by_path(self):
|
||||
# Create two files with different paths
|
||||
first_file = factories.FileFactory(path="/root/roles/foo/tasks/main.yml")
|
||||
second_file = factories.FileFactory(path="/root/roles/bar/tasks/main.yml")
|
||||
|
||||
# Create two tasks using these files
|
||||
task = factories.TaskFactory(file=first_file)
|
||||
factories.TaskFactory(file=second_file)
|
||||
|
||||
# Test exact match
|
||||
request = self.client.get("/api/v1/tasks?path=/root/roles/foo/tasks/main.yml")
|
||||
self.assertEqual(1, len(request.data["results"]))
|
||||
self.assertEqual(task.file.path, request.data["results"][0]["path"])
|
||||
|
||||
# Test partial match
|
||||
request = self.client.get("/api/v1/tasks?path=main.yml")
|
||||
self.assertEqual(len(request.data["results"]), 2)
|
@ -1,29 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pkg_resources
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
|
||||
class RootTestCase(APITestCase):
|
||||
def test_root_endpoint(self):
|
||||
result = self.client.get("/api/")
|
||||
self.assertEqual(set(result.data.keys()), set(["kind", "version", "api"]))
|
||||
self.assertEqual(result.data["kind"], "ara")
|
||||
self.assertEqual(result.data["version"], pkg_resources.get_distribution("ara").version)
|
||||
self.assertTrue(len(result.data["api"]), 1)
|
||||
self.assertTrue(result.data["api"][0].endswith("/api/v1/"))
|
@ -1,41 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zlib
|
||||
|
||||
|
||||
def compressed_obj(obj):
|
||||
"""
|
||||
Returns a zlib compressed representation of an object
|
||||
"""
|
||||
return zlib.compress(json.dumps(obj).encode("utf-8"))
|
||||
|
||||
|
||||
def compressed_str(obj):
|
||||
"""
|
||||
Returns a zlib compressed representation of a string
|
||||
"""
|
||||
return zlib.compress(obj.encode("utf-8"))
|
||||
|
||||
|
||||
def sha1(obj):
|
||||
"""
|
||||
Returns the sha1 of a compressed string or an object
|
||||
"""
|
||||
return hashlib.sha1(obj.encode("utf8")).hexdigest()
|
@ -1,32 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from ara.api import views
|
||||
|
||||
router = DefaultRouter(trailing_slash=False)
|
||||
router.register("labels", views.LabelViewSet, basename="label")
|
||||
router.register("playbooks", views.PlaybookViewSet, basename="playbook")
|
||||
router.register("plays", views.PlayViewSet, basename="play")
|
||||
router.register("tasks", views.TaskViewSet, basename="task")
|
||||
router.register("hosts", views.HostViewSet, basename="host")
|
||||
router.register("results", views.ResultViewSet, basename="result")
|
||||
router.register("files", views.FileViewSet, basename="file")
|
||||
router.register("records", views.RecordViewSet, basename="record")
|
||||
|
||||
urlpatterns = router.urls
|
152
ara/api/views.py
152
ara/api/views.py
@ -1,152 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from rest_framework import viewsets
|
||||
|
||||
from ara.api import filters, models, serializers
|
||||
|
||||
|
||||
class LabelViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Label.objects.all()
|
||||
filterset_class = filters.LabelFilter
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.ListLabelSerializer
|
||||
elif self.action == "retrieve":
|
||||
return serializers.DetailedLabelSerializer
|
||||
else:
|
||||
# create/update/destroy
|
||||
return serializers.LabelSerializer
|
||||
|
||||
|
||||
class PlaybookViewSet(viewsets.ModelViewSet):
|
||||
filterset_class = filters.PlaybookFilter
|
||||
|
||||
def get_queryset(self):
|
||||
statuses = self.request.GET.getlist("status")
|
||||
if statuses:
|
||||
return models.Playbook.objects.filter(status__in=statuses).order_by("-id")
|
||||
return models.Playbook.objects.all().order_by("-id")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.ListPlaybookSerializer
|
||||
elif self.action == "retrieve":
|
||||
return serializers.DetailedPlaybookSerializer
|
||||
else:
|
||||
# create/update/destroy
|
||||
return serializers.PlaybookSerializer
|
||||
|
||||
|
||||
class PlayViewSet(viewsets.ModelViewSet):
|
||||
filterset_class = filters.PlayFilter
|
||||
|
||||
def get_queryset(self):
|
||||
statuses = self.request.GET.getlist("status")
|
||||
if statuses:
|
||||
return models.Play.objects.filter(status__in=statuses).order_by("-id")
|
||||
return models.Play.objects.all().order_by("-id")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.ListPlaySerializer
|
||||
elif self.action == "retrieve":
|
||||
return serializers.DetailedPlaySerializer
|
||||
else:
|
||||
# create/update/destroy
|
||||
return serializers.PlaySerializer
|
||||
|
||||
|
||||
class TaskViewSet(viewsets.ModelViewSet):
|
||||
filterset_class = filters.TaskFilter
|
||||
|
||||
def get_queryset(self):
|
||||
statuses = self.request.GET.getlist("status")
|
||||
if statuses:
|
||||
return models.Task.objects.filter(status__in=statuses).order_by("-id")
|
||||
return models.Task.objects.all().order_by("-id")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.ListTaskSerializer
|
||||
elif self.action == "retrieve":
|
||||
return serializers.DetailedTaskSerializer
|
||||
else:
|
||||
# create/update/destroy
|
||||
return serializers.TaskSerializer
|
||||
|
||||
|
||||
class HostViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Host.objects.all()
|
||||
filterset_class = filters.HostFilter
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.ListHostSerializer
|
||||
elif self.action == "retrieve":
|
||||
return serializers.DetailedHostSerializer
|
||||
else:
|
||||
# create/update/destroy
|
||||
return serializers.HostSerializer
|
||||
|
||||
|
||||
class ResultViewSet(viewsets.ModelViewSet):
|
||||
filterset_class = filters.ResultFilter
|
||||
|
||||
def get_queryset(self):
|
||||
statuses = self.request.GET.getlist("status")
|
||||
if statuses:
|
||||
return models.Result.objects.filter(status__in=statuses).order_by("-id")
|
||||
return models.Result.objects.all().order_by("-id")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.ListResultSerializer
|
||||
elif self.action == "retrieve":
|
||||
return serializers.DetailedResultSerializer
|
||||
else:
|
||||
# create/update/destroy
|
||||
return serializers.ResultSerializer
|
||||
|
||||
|
||||
class FileViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.File.objects.all()
|
||||
filterset_class = filters.FileFilter
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.ListFileSerializer
|
||||
elif self.action == "retrieve":
|
||||
return serializers.DetailedFileSerializer
|
||||
else:
|
||||
# create/update/destroy
|
||||
return serializers.FileSerializer
|
||||
|
||||
|
||||
class RecordViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Record.objects.all()
|
||||
filterset_class = filters.RecordFilter
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.ListRecordSerializer
|
||||
elif self.action == "retrieve":
|
||||
return serializers.DetailedRecordSerializer
|
||||
else:
|
||||
# create/update/destroy
|
||||
return serializers.RecordSerializer
|
@ -1,89 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pbr.version
|
||||
from cliff.app import App
|
||||
from cliff.commandmanager import CommandManager
|
||||
|
||||
CLIENT_VERSION = pbr.version.VersionInfo("ara").release_string()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def global_arguments(parser):
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"--client",
|
||||
metavar="<client>",
|
||||
default=os.environ.get("ARA_API_CLIENT", "offline"),
|
||||
help=("API client to use, defaults to ARA_API_CLIENT or 'offline'"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server",
|
||||
metavar="<url>",
|
||||
default=os.environ.get("ARA_API_SERVER", "http://127.0.0.1:8000"),
|
||||
help=("API server endpoint if using http client, defaults to ARA_API_SERVER or 'http://127.0.0.1:8000'"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
metavar="<seconds>",
|
||||
default=os.environ.get("ARA_API_TIMEOUT", 30),
|
||||
help=("Timeout for requests to API server, defaults to ARA_API_TIMEOUT or 30"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
metavar="<username>",
|
||||
default=os.environ.get("ARA_API_USERNAME", None),
|
||||
help=("API server username for authentication, defaults to ARA_API_USERNAME or None"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
metavar="<password>",
|
||||
default=os.environ.get("ARA_API_PASSWORD", None),
|
||||
help=("API server password for authentication, defaults to ARA_API_PASSWORD or None"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--insecure",
|
||||
action="store_true",
|
||||
default=os.environ.get("ARA_API_INSECURE", False),
|
||||
help=("Ignore SSL certificate validation, defaults to ARA_API_INSECURE or False"),
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
|
||||
class AraCli(App):
|
||||
def __init__(self):
|
||||
super(AraCli, self).__init__(
|
||||
description="A CLI client to query ARA API servers",
|
||||
version=CLIENT_VERSION,
|
||||
command_manager=CommandManager("ara.cli"),
|
||||
deferred_help=True,
|
||||
)
|
||||
|
||||
def build_option_parser(self, description, version):
|
||||
parser = super(AraCli, self).build_option_parser(description, version)
|
||||
return parser
|
||||
|
||||
def initialize_app(self, argv):
|
||||
log.debug("initialize_app")
|
||||
|
||||
def prepare_to_run_command(self, cmd):
|
||||
log.debug("prepare_to_run_command: %s", cmd.__class__.__name__)
|
||||
|
||||
def clean_up(self, cmd, result, err):
|
||||
log.debug("clean_up %s", cmd.__class__.__name__)
|
||||
if err:
|
||||
log.debug("got an error: %s", err)
|
||||
|
||||
|
||||
def main(argv=sys.argv[1:]):
|
||||
aracli = AraCli()
|
||||
return aracli.run(argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
@ -1,101 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# See https://github.com/ansible-community/ara/issues/26 for rationale on expiring
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from cliff.command import Command
|
||||
|
||||
from ara.cli.base import global_arguments
|
||||
from ara.clients.utils import get_client
|
||||
|
||||
|
||||
class ExpireObjects(Command):
|
||||
""" Expires objects that have been in the running state for too long """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
expired = 0
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(ExpireObjects, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"--hours",
|
||||
type=int,
|
||||
default=24,
|
||||
help="Expires objects that have been running state for this many hours (default: 24)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="started",
|
||||
help=(
|
||||
"Orders objects by a field ('id', 'created', 'updated', 'started', 'ended')\n"
|
||||
"Defaults to 'started' descending so the oldest objects would be expired first.\n"
|
||||
"The order can be reversed by using '-': ara expire --order=-started"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 200),
|
||||
help=("Only expire the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 200.")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--confirm",
|
||||
action="store_true",
|
||||
help="Confirm expiration of objects, otherwise runs without expiring any objects",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
if not args.confirm:
|
||||
self.log.info("--confirm was not specified, no objects will be expired")
|
||||
|
||||
query = dict(status="running")
|
||||
# generate a timestamp from n days ago in a format we can query the API with
|
||||
# ex: 2019-11-21T00:57:41.702229
|
||||
query["updated_before"] = (datetime.now() - timedelta(hours=args.hours)).isoformat()
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
endpoints = ["/api/v1/playbooks", "/api/v1/plays", "/api/v1/tasks"]
|
||||
for endpoint in endpoints:
|
||||
objects = client.get(endpoint, **query)
|
||||
self.log.info("Found %s objects matching query on %s" % (objects["count"], endpoint))
|
||||
# TODO: Improve client validation and exception handling
|
||||
if "count" not in objects:
|
||||
# If we didn't get an answer we can parse, it's probably due to an error 500, 403, 401, etc.
|
||||
# The client would have logged the error.
|
||||
self.log.error(
|
||||
"Client failed to retrieve results, see logs for ara.clients.offline or ara.clients.http."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
for obj in objects["results"]:
|
||||
link = "%s/%s" % (endpoint, obj["id"])
|
||||
if not args.confirm:
|
||||
self.log.info(
|
||||
"Dry-run: %s would have been expired, status is running since %s" % (link, obj["updated"])
|
||||
)
|
||||
else:
|
||||
self.log.info("Expiring %s, status is running since %s" % (link, obj["updated"]))
|
||||
client.patch(link, status="expired")
|
||||
self.expired += 1
|
||||
|
||||
self.log.info("%s objects expired" % self.expired)
|
428
ara/cli/host.py
428
ara/cli/host.py
@ -1,428 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from cliff.command import Command
|
||||
from cliff.lister import Lister
|
||||
from cliff.show import ShowOne
|
||||
|
||||
import ara.cli.utils as cli_utils
|
||||
from ara.cli.base import global_arguments
|
||||
from ara.clients.utils import get_client
|
||||
|
||||
|
||||
class HostList(Lister):
|
||||
""" Returns a list of hosts based on search queries """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(HostList, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
# Host search arguments and ordering as per ara.api.filters.HostFilter
|
||||
# TODO: non-exhaustive (searching for failed, ok, unreachable, etc.)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
metavar="<name>",
|
||||
default=None,
|
||||
help=("List hosts matching the provided name (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--playbook",
|
||||
metavar="<playbook_id>",
|
||||
default=None,
|
||||
help=("List hosts for a specified playbook id"),
|
||||
)
|
||||
|
||||
changed = parser.add_mutually_exclusive_group()
|
||||
changed.add_argument(
|
||||
"--with-changed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Return hosts with changed results")
|
||||
)
|
||||
changed.add_argument(
|
||||
"--without-changed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't return hosts with changed results")
|
||||
)
|
||||
|
||||
failed = parser.add_mutually_exclusive_group()
|
||||
failed.add_argument(
|
||||
"--with-failed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Return hosts with failed results")
|
||||
)
|
||||
failed.add_argument(
|
||||
"--without-failed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't return hosts with failed results")
|
||||
)
|
||||
|
||||
unreachable = parser.add_mutually_exclusive_group()
|
||||
unreachable.add_argument(
|
||||
"--with-unreachable",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Return hosts with unreachable results")
|
||||
)
|
||||
unreachable.add_argument(
|
||||
"--without-unreachable",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't return hosts with unreachable results")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolve",
|
||||
action="store_true",
|
||||
default=os.environ.get("ARA_CLI_RESOLVE", False),
|
||||
help=("Resolve IDs to identifiers (such as path or names). Defaults to ARA_CLI_RESOLVE or False")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--long",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't truncate paths")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-updated",
|
||||
help=(
|
||||
"Orders hosts by a field ('id', 'created', 'updated', 'name')\n"
|
||||
"Defaults to '-updated' descending so the most recent host is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara host list --order=updated"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 50),
|
||||
help=("Returns the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 50.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.name is not None:
|
||||
query["name"] = args.name
|
||||
|
||||
if args.playbook is not None:
|
||||
query["playbook"] = args.playbook
|
||||
|
||||
if args.with_changed:
|
||||
query["changed__gt"] = 0
|
||||
if args.without_changed:
|
||||
query["changed__lt"] = 1
|
||||
if args.with_failed:
|
||||
query["failed__gt"] = 0
|
||||
if args.without_failed:
|
||||
query["failed__lt"] = 1
|
||||
if args.with_unreachable:
|
||||
query["unreachable__gt"] = 0
|
||||
if args.without_unreachable:
|
||||
query["unreachable__lt"] = 1
|
||||
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
hosts = client.get("/api/v1/hosts", **query)
|
||||
|
||||
if args.resolve:
|
||||
for host in hosts["results"]:
|
||||
playbook = cli_utils.get_playbook(client, host["playbook"])
|
||||
# Paths can easily take up too much width real estate
|
||||
if not args.long:
|
||||
host["playbook"] = "(%s) %s" % (playbook["id"], cli_utils.truncatepath(playbook["path"], 50))
|
||||
else:
|
||||
host["playbook"] = "(%s) %s" % (playbook["id"], playbook["path"])
|
||||
|
||||
columns = ("id", "name", "playbook", "changed", "failed", "ok", "skipped", "unreachable", "updated")
|
||||
# fmt: off
|
||||
return (
|
||||
columns, (
|
||||
[host[column] for column in columns]
|
||||
for host in hosts["results"]
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class HostShow(ShowOne):
|
||||
""" Returns a detailed view of a specified host """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(HostShow, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"host_id",
|
||||
metavar="<host-id>",
|
||||
help="Host to show",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--with-facts",
|
||||
action="store_true",
|
||||
help="Also include host facts in the response (use with '-f json' or '-f yaml')"
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
# TODO: Render json properly in pretty tables
|
||||
if args.with_facts and args.formatter == "table":
|
||||
self.log.warn("Rendering using default table formatter, use '-f yaml' or '-f json' for improved display.")
|
||||
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
host = client.get("/api/v1/hosts/%s" % args.host_id)
|
||||
if "detail" in host and host["detail"] == "Not found.":
|
||||
self.log.error("Host not found: %s" % args.host_id)
|
||||
sys.exit(1)
|
||||
|
||||
host["report"] = "%s/playbooks/%s.html" % (args.server, host["playbook"]["id"])
|
||||
if args.with_facts:
|
||||
# fmt: off
|
||||
columns = (
|
||||
"id",
|
||||
"report",
|
||||
"name",
|
||||
"changed",
|
||||
"failed",
|
||||
"ok",
|
||||
"skipped",
|
||||
"unreachable",
|
||||
"facts",
|
||||
"updated"
|
||||
)
|
||||
# fmt: on
|
||||
else:
|
||||
# fmt: off
|
||||
columns = (
|
||||
"id",
|
||||
"report",
|
||||
"name",
|
||||
"changed",
|
||||
"failed",
|
||||
"ok",
|
||||
"skipped",
|
||||
"unreachable",
|
||||
"updated"
|
||||
)
|
||||
# fmt: on
|
||||
return (columns, ([host[column] for column in columns]))
|
||||
|
||||
|
||||
class HostDelete(Command):
|
||||
""" Deletes the specified host and associated resources """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(HostDelete, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"host_id",
|
||||
metavar="<host-id>",
|
||||
help="Host to delete",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
client.delete("/api/v1/hosts/%s" % args.host_id)
|
||||
|
||||
|
||||
class HostMetrics(Lister):
|
||||
""" Provides metrics about hosts """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(HostMetrics, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
# Host search arguments and ordering as per ara.api.filters.HostFilter
|
||||
# TODO: non-exhaustive (searching for failed, ok, unreachable, etc.)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
metavar="<name>",
|
||||
default=None,
|
||||
help=("Filter for hosts matching the provided name (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--playbook",
|
||||
metavar="<playbook_id>",
|
||||
default=None,
|
||||
help=("Filter for hosts for a specified playbook id"),
|
||||
)
|
||||
|
||||
changed = parser.add_mutually_exclusive_group()
|
||||
changed.add_argument(
|
||||
"--with-changed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Filter for hosts with changed results")
|
||||
)
|
||||
changed.add_argument(
|
||||
"--without-changed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Filter out hosts without changed results")
|
||||
)
|
||||
|
||||
failed = parser.add_mutually_exclusive_group()
|
||||
failed.add_argument(
|
||||
"--with-failed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Filter for hosts with failed results")
|
||||
)
|
||||
failed.add_argument(
|
||||
"--without-failed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Filter out hosts without failed results")
|
||||
)
|
||||
|
||||
unreachable = parser.add_mutually_exclusive_group()
|
||||
unreachable.add_argument(
|
||||
"--with-unreachable",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Filter for hosts with unreachable results")
|
||||
)
|
||||
unreachable.add_argument(
|
||||
"--without-unreachable",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Filter out hosts without unreachable results")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-updated",
|
||||
help=(
|
||||
"Orders hosts by a field ('id', 'created', 'updated', 'name')\n"
|
||||
"Defaults to '-updated' descending so the most recent host is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara host list --order=updated\n"
|
||||
"This influences the API request, not the ordering of the metrics."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 1000),
|
||||
help=("Return metrics for the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 1000.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.name is not None:
|
||||
query["name"] = args.name
|
||||
|
||||
if args.playbook is not None:
|
||||
query["playbook"] = args.playbook
|
||||
|
||||
if args.with_changed:
|
||||
query["changed__gt"] = 0
|
||||
if args.without_changed:
|
||||
query["changed__lt"] = 1
|
||||
if args.with_failed:
|
||||
query["failed__gt"] = 0
|
||||
if args.without_failed:
|
||||
query["failed__lt"] = 1
|
||||
if args.with_unreachable:
|
||||
query["unreachable__gt"] = 0
|
||||
if args.without_unreachable:
|
||||
query["unreachable__lt"] = 1
|
||||
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
resp = client.get("/api/v1/hosts", **query)
|
||||
|
||||
# Group hosts by name
|
||||
hosts = {}
|
||||
for host in resp["results"]:
|
||||
name = host["name"]
|
||||
if name not in hosts:
|
||||
hosts[name] = []
|
||||
hosts[name].append(host)
|
||||
|
||||
data = {}
|
||||
for name, host_results in hosts.items():
|
||||
data[name] = {
|
||||
"name": name,
|
||||
"count": len(host_results),
|
||||
"changed": 0,
|
||||
"failed": 0,
|
||||
"ok": 0,
|
||||
"skipped": 0,
|
||||
"unreachable": 0,
|
||||
}
|
||||
|
||||
for host in host_results:
|
||||
for status in ["changed", "failed", "ok", "skipped", "unreachable"]:
|
||||
data[name][status] += host[status]
|
||||
|
||||
columns = ("name", "count", "changed", "failed", "ok", "skipped", "unreachable")
|
||||
# fmt: off
|
||||
return (
|
||||
columns, (
|
||||
[data[host][column] for column in columns]
|
||||
for host in sorted(data.keys())
|
||||
)
|
||||
)
|
||||
# fmt: on
|
216
ara/cli/play.py
216
ara/cli/play.py
@ -1,216 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from cliff.command import Command
|
||||
from cliff.lister import Lister
|
||||
from cliff.show import ShowOne
|
||||
|
||||
import ara.cli.utils as cli_utils
|
||||
from ara.cli.base import global_arguments
|
||||
from ara.clients.utils import get_client
|
||||
|
||||
|
||||
class PlayList(Lister):
|
||||
""" Returns a list of plays based on search queries """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(PlayList, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
# Play search arguments and ordering as per ara.api.filters.PlayFilter
|
||||
parser.add_argument(
|
||||
"--playbook",
|
||||
metavar="<playbook_id>",
|
||||
default=None,
|
||||
help=("List plays for the specified playbook"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
metavar="<name>",
|
||||
default=None,
|
||||
help=("List plays matching the provided name (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--uuid",
|
||||
metavar="<uuid>",
|
||||
default=None,
|
||||
help=("List plays matching the provided uuid (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
metavar="<status>",
|
||||
default=None,
|
||||
help=("List plays matching a specific status ('completed', 'running', 'failed')"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--long",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't truncate paths")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolve",
|
||||
action="store_true",
|
||||
default=os.environ.get("ARA_CLI_RESOLVE", False),
|
||||
help=("Resolve IDs to identifiers (such as path or names). Defaults to ARA_CLI_RESOLVE or False")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-started",
|
||||
help=(
|
||||
"Orders plays by a field ('id', 'created', 'updated', 'started', 'ended', 'duration')\n"
|
||||
"Defaults to '-started' descending so the most recent playbook is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara play list --order=started"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 50),
|
||||
help=("Returns the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 50.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.playbook is not None:
|
||||
query["playbook"] = args.playbook
|
||||
|
||||
if args.name is not None:
|
||||
query["name"] = args.name
|
||||
|
||||
if args.uuid is not None:
|
||||
query["uuid"] = args.uuid
|
||||
|
||||
if args.status is not None:
|
||||
query["status"] = args.status
|
||||
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
plays = client.get("/api/v1/plays", **query)
|
||||
for play in plays["results"]:
|
||||
# Send items to columns
|
||||
play["tasks"] = play["items"]["tasks"]
|
||||
play["results"] = play["items"]["results"]
|
||||
|
||||
if args.resolve:
|
||||
playbook = cli_utils.get_playbook(client, play["playbook"])
|
||||
# Paths can easily take up too much width real estate
|
||||
if not args.long:
|
||||
play["playbook"] = "(%s) %s" % (playbook["id"], cli_utils.truncatepath(playbook["path"], 50))
|
||||
else:
|
||||
play["playbook"] = "(%s) %s" % (playbook["id"], playbook["path"])
|
||||
|
||||
columns = ("id", "status", "name", "playbook", "tasks", "results", "started", "duration")
|
||||
# fmt: off
|
||||
return (
|
||||
columns, (
|
||||
[play[column] for column in columns]
|
||||
for play in plays["results"]
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class PlayShow(ShowOne):
|
||||
""" Returns a detailed view of a specified play """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(PlayShow, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"play_id",
|
||||
metavar="<play-id>",
|
||||
help="Play to show",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
play = client.get("/api/v1/plays/%s" % args.play_id)
|
||||
if "detail" in play and play["detail"] == "Not found.":
|
||||
self.log.error("Play not found: %s" % args.play_id)
|
||||
sys.exit(1)
|
||||
|
||||
playbook = "(%s) %s" % (play["playbook"]["id"], play["playbook"]["name"] or play["playbook"]["path"])
|
||||
play["report"] = "%s/playbooks/%s.html" % (args.server, play["playbook"]["id"])
|
||||
play["playbook"] = playbook
|
||||
|
||||
# fmt: off
|
||||
columns = (
|
||||
"id",
|
||||
"report",
|
||||
"status",
|
||||
"name",
|
||||
"playbook",
|
||||
"started",
|
||||
"ended",
|
||||
"duration",
|
||||
"items",
|
||||
)
|
||||
# fmt: on
|
||||
return (columns, ([play[column] for column in columns]))
|
||||
|
||||
|
||||
class PlayDelete(Command):
|
||||
""" Deletes the specified play and associated resources """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(PlayDelete, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"play_id",
|
||||
metavar="<play-id>",
|
||||
help="Play to delete",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
client.delete("/api/v1/plays/%s" % args.play_id)
|
@ -1,594 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from cliff.command import Command
|
||||
from cliff.lister import Lister
|
||||
from cliff.show import ShowOne
|
||||
|
||||
import ara.cli.utils as cli_utils
|
||||
from ara.cli.base import global_arguments
|
||||
from ara.clients.utils import get_client
|
||||
|
||||
|
||||
class PlaybookList(Lister):
|
||||
""" Returns a list of playbooks based on search queries """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(PlaybookList, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
# Playbook search arguments and ordering as per ara.api.filters.PlaybookFilter
|
||||
parser.add_argument(
|
||||
"--label",
|
||||
metavar="<label>",
|
||||
default=None,
|
||||
help=("List playbooks matching the provided label"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ansible_version",
|
||||
metavar="<ansible_version>",
|
||||
default=None,
|
||||
help=("List playbooks that ran with the specified Ansible version (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--controller",
|
||||
metavar="<controller>",
|
||||
default=None,
|
||||
help=("List playbooks that ran from the provided controller (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
metavar="<name>",
|
||||
default=None,
|
||||
help=("List playbooks matching the provided name (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
metavar="<path>",
|
||||
default=None,
|
||||
help=("List playbooks matching the provided path (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
metavar="<status>",
|
||||
default=None,
|
||||
help=("List playbooks matching a specific status ('completed', 'running', 'failed')"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--long",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't truncate paths and include additional fields: name, plays, files, records")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-started",
|
||||
help=(
|
||||
"Orders playbooks by a field ('id', 'created', 'updated', 'started', 'ended', 'duration')\n"
|
||||
"Defaults to '-started' descending so the most recent playbook is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara playbook list --order=started"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 50),
|
||||
help=("Returns the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 50.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.label is not None:
|
||||
query["label"] = args.label
|
||||
|
||||
if args.ansible_version is not None:
|
||||
query["ansible_version"] = args.ansible_version
|
||||
|
||||
if args.controller is not None:
|
||||
query["controller"] = args.controller
|
||||
|
||||
if args.name is not None:
|
||||
query["name"] = args.name
|
||||
|
||||
if args.path is not None:
|
||||
query["path"] = args.path
|
||||
|
||||
if args.status is not None:
|
||||
query["status"] = args.status
|
||||
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
playbooks = client.get("/api/v1/playbooks", **query)
|
||||
for playbook in playbooks["results"]:
|
||||
# Send items to columns
|
||||
playbook["plays"] = playbook["items"]["plays"]
|
||||
playbook["tasks"] = playbook["items"]["tasks"]
|
||||
playbook["results"] = playbook["items"]["results"]
|
||||
playbook["hosts"] = playbook["items"]["hosts"]
|
||||
playbook["files"] = playbook["items"]["files"]
|
||||
playbook["records"] = playbook["items"]["records"]
|
||||
# Paths can easily take up too much width real estate
|
||||
if not args.long:
|
||||
playbook["path"] = cli_utils.truncatepath(playbook["path"], 50)
|
||||
|
||||
# fmt: off
|
||||
if args.long:
|
||||
columns = (
|
||||
"id",
|
||||
"status",
|
||||
"controller",
|
||||
"ansible_version",
|
||||
"name",
|
||||
"path",
|
||||
"plays",
|
||||
"tasks",
|
||||
"results",
|
||||
"hosts",
|
||||
"files",
|
||||
"records",
|
||||
"started",
|
||||
"duration"
|
||||
)
|
||||
else:
|
||||
columns = (
|
||||
"id",
|
||||
"status",
|
||||
"controller",
|
||||
"ansible_version",
|
||||
"path",
|
||||
"tasks",
|
||||
"results",
|
||||
"hosts",
|
||||
"started",
|
||||
"duration"
|
||||
)
|
||||
return (
|
||||
columns, (
|
||||
[playbook[column] for column in columns]
|
||||
for playbook in playbooks["results"]
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class PlaybookShow(ShowOne):
|
||||
""" Returns a detailed view of a specified playbook """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(PlaybookShow, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"playbook_id",
|
||||
metavar="<playbook-id>",
|
||||
help="Playbook to show",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
# TODO: Render json properly in pretty tables
|
||||
if args.formatter == "table":
|
||||
self.log.warn("Rendering using default table formatter, use '-f yaml' or '-f json' for improved display.")
|
||||
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
playbook = client.get("/api/v1/playbooks/%s" % args.playbook_id)
|
||||
if "detail" in playbook and playbook["detail"] == "Not found.":
|
||||
self.log.error("Playbook not found: %s" % args.playbook_id)
|
||||
sys.exit(1)
|
||||
|
||||
playbook["report"] = "%s/playbooks/%s.html" % (args.server, args.playbook_id)
|
||||
columns = (
|
||||
"id",
|
||||
"report",
|
||||
"controller",
|
||||
"ansible_version",
|
||||
"status",
|
||||
"path",
|
||||
"started",
|
||||
"ended",
|
||||
"duration",
|
||||
"items",
|
||||
"labels",
|
||||
"arguments",
|
||||
)
|
||||
return (columns, ([playbook[column] for column in columns]))
|
||||
|
||||
|
||||
class PlaybookDelete(Command):
|
||||
""" Deletes the specified playbook and associated resources """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(PlaybookDelete, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"playbook_id",
|
||||
metavar="<playbook-id>",
|
||||
help="Playbook to delete",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
client.delete("/api/v1/playbooks/%s" % args.playbook_id)
|
||||
|
||||
|
||||
class PlaybookPrune(Command):
|
||||
""" Deletes playbooks beyond a specified age in days """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
deleted = 0
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(PlaybookPrune, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"--days", type=int, default=31, help="Delete playbooks started this many days ago (default: 31)"
|
||||
)
|
||||
# Playbook search arguments like 'ara playbook list'
|
||||
parser.add_argument(
|
||||
"--label",
|
||||
metavar="<label>",
|
||||
default=None,
|
||||
help=("Only delete playbooks matching the provided label"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
metavar="<name>",
|
||||
default=None,
|
||||
help=("Only delete playbooks matching the provided name (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ansible_version",
|
||||
metavar="<ansible_version>",
|
||||
default=None,
|
||||
help=("Only delete playbooks that ran with the specified Ansible version (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--controller",
|
||||
metavar="<controller>",
|
||||
default=None,
|
||||
help=("Only delete playbooks that ran from the provided controller (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
metavar="<path>",
|
||||
default=None,
|
||||
help=("Only delete only playbooks matching the provided path (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
metavar="<status>",
|
||||
default=None,
|
||||
help=("Only delete playbooks matching a specific status ('completed', 'running', 'failed')"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="started",
|
||||
help=(
|
||||
"Orders playbooks by a field ('id', 'created', 'updated', 'started', 'ended', 'duration')\n"
|
||||
"Defaults to 'started' descending so the oldest playbook would be deleted first.\n"
|
||||
"The order can be reversed by using '-': ara playbook list --order=-started"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 200),
|
||||
help=("Only delete the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 200.")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--confirm",
|
||||
action="store_true",
|
||||
help="Confirm deletion of playbooks, otherwise runs without deleting any playbook",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
if not args.confirm:
|
||||
self.log.info("--confirm was not specified, no playbooks will be deleted")
|
||||
|
||||
query = {}
|
||||
if args.label is not None:
|
||||
query["label"] = args.label
|
||||
|
||||
if args.ansible_version is not None:
|
||||
query["ansible_version"] = args.ansible_version
|
||||
|
||||
if args.controller is not None:
|
||||
query["controller"] = args.controller
|
||||
|
||||
if args.name is not None:
|
||||
query["name"] = args.name
|
||||
|
||||
if args.path is not None:
|
||||
query["path"] = args.path
|
||||
|
||||
if args.status is not None:
|
||||
query["status"] = args.status
|
||||
|
||||
# generate a timestamp from n days ago in a format we can query the API with
|
||||
# ex: 2019-11-21T00:57:41.702229
|
||||
query["started_before"] = (datetime.now() - timedelta(days=args.days)).isoformat()
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
playbooks = client.get("/api/v1/playbooks", **query)
|
||||
|
||||
# TODO: Improve client validation and exception handling
|
||||
if "count" not in playbooks:
|
||||
# If we didn't get an answer we can parse, it's probably due to an error 500, 403, 401, etc.
|
||||
# The client would have logged the error.
|
||||
self.log.error("Client failed to retrieve results, see logs for ara.clients.offline or ara.clients.http.")
|
||||
sys.exit(1)
|
||||
|
||||
self.log.info("Found %s playbooks matching query" % playbooks["count"])
|
||||
for playbook in playbooks["results"]:
|
||||
if not args.confirm:
|
||||
msg = "Dry-run: playbook {id} ({path}) would have been deleted, start date: {started}"
|
||||
self.log.info(msg.format(id=playbook["id"], path=playbook["path"], started=playbook["started"]))
|
||||
else:
|
||||
msg = "Deleting playbook {id} ({path}), start date: {started}"
|
||||
self.log.info(msg.format(id=playbook["id"], path=playbook["path"], started=playbook["started"]))
|
||||
client.delete("/api/v1/playbooks/%s" % playbook["id"])
|
||||
self.deleted += 1
|
||||
|
||||
self.log.info("%s playbooks deleted" % self.deleted)
|
||||
|
||||
|
||||
class PlaybookMetrics(Lister):
|
||||
""" Provides metrics about playbooks """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(PlaybookMetrics, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"--aggregate",
|
||||
choices=["name", "path", "ansible_version", "controller"],
|
||||
default="path",
|
||||
help=("Aggregate playbooks by path, name, ansible version or controller. Defaults to path."),
|
||||
)
|
||||
# Playbook search arguments and ordering as per ara.api.filters.PlaybookFilter
|
||||
parser.add_argument(
|
||||
"--label",
|
||||
metavar="<label>",
|
||||
default=None,
|
||||
help=("List playbooks matching the provided label"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ansible_version",
|
||||
metavar="<ansible_version>",
|
||||
default=None,
|
||||
help=("List playbooks that ran with the specified Ansible version (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
metavar="<name>",
|
||||
default=None,
|
||||
help=("List playbooks matching the provided name (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--controller",
|
||||
metavar="<controller>",
|
||||
default=None,
|
||||
help=("List playbooks that ran from the provided controller (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
metavar="<path>",
|
||||
default=None,
|
||||
help=("List playbooks matching the provided path (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
metavar="<status>",
|
||||
default=None,
|
||||
help=("List playbooks matching a specific status ('completed', 'running', 'failed')"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--long",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't truncate paths and include additional fields: name, plays, files, records")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-started",
|
||||
help=(
|
||||
"Orders playbooks by a field ('id', 'created', 'updated', 'started', 'ended', 'duration')\n"
|
||||
"Defaults to '-started' descending so the most recent playbook is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara playbook list --order=started"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 1000),
|
||||
help=("Returns the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 1000.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.label is not None:
|
||||
query["label"] = args.label
|
||||
|
||||
if args.ansible_version is not None:
|
||||
query["ansible_version"] = args.ansible_version
|
||||
|
||||
if args.name is not None:
|
||||
query["name"] = args.name
|
||||
|
||||
if args.controller is not None:
|
||||
query["controller"] = args.controller
|
||||
|
||||
if args.path is not None:
|
||||
query["path"] = args.path
|
||||
|
||||
if args.status is not None:
|
||||
query["status"] = args.status
|
||||
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
playbooks = client.get("/api/v1/playbooks", **query)
|
||||
|
||||
# TODO: This could probably be made more efficient without needing to iterate a second time
|
||||
# Group playbooks by aggregate
|
||||
aggregate = {}
|
||||
for playbook in playbooks["results"]:
|
||||
item = playbook[args.aggregate]
|
||||
if item not in aggregate:
|
||||
aggregate[item] = []
|
||||
aggregate[item].append(playbook)
|
||||
|
||||
data = {}
|
||||
for item, playbooks in aggregate.items():
|
||||
data[item] = {
|
||||
"count": len(playbooks),
|
||||
"hosts": 0,
|
||||
"plays": 0,
|
||||
"tasks": 0,
|
||||
"results": 0,
|
||||
"files": 0,
|
||||
"records": 0,
|
||||
"expired": 0,
|
||||
"failed": 0,
|
||||
"running": 0,
|
||||
"completed": 0,
|
||||
"unknown": 0,
|
||||
"duration_total": "00:00:00.000000",
|
||||
}
|
||||
|
||||
if args.aggregate == "path" and not args.long:
|
||||
data[item]["aggregate"] = cli_utils.truncatepath(item, 50)
|
||||
else:
|
||||
data[item]["aggregate"] = item
|
||||
|
||||
for playbook in playbooks:
|
||||
for status in ["completed", "expired", "failed", "running", "unknown"]:
|
||||
if playbook["status"] == status:
|
||||
data[item][status] += 1
|
||||
|
||||
for obj in ["files", "hosts", "plays", "tasks", "records", "results"]:
|
||||
data[item][obj] += playbook["items"][obj]
|
||||
|
||||
if playbook["duration"] is not None:
|
||||
data[item]["duration_total"] = cli_utils.sum_timedelta(
|
||||
playbook["duration"], data[item]["duration_total"]
|
||||
)
|
||||
|
||||
data[item]["duration_avg"] = cli_utils.avg_timedelta(data[item]["duration_total"], data[item]["count"])
|
||||
|
||||
# fmt: off
|
||||
if args.long:
|
||||
columns = (
|
||||
"aggregate",
|
||||
"count",
|
||||
"duration_total",
|
||||
"duration_avg",
|
||||
"plays",
|
||||
"tasks",
|
||||
"results",
|
||||
"hosts",
|
||||
"files",
|
||||
"records",
|
||||
"completed",
|
||||
"expired",
|
||||
"failed",
|
||||
"running",
|
||||
"unknown"
|
||||
)
|
||||
else:
|
||||
columns = (
|
||||
"aggregate",
|
||||
"count",
|
||||
"duration_total",
|
||||
"duration_avg",
|
||||
"tasks",
|
||||
"results",
|
||||
"hosts",
|
||||
"completed",
|
||||
"failed",
|
||||
"running"
|
||||
)
|
||||
return (
|
||||
columns, (
|
||||
[data[playbook][column] for column in columns]
|
||||
for playbook in sorted(data.keys())
|
||||
)
|
||||
)
|
||||
# fmt: on
|
@ -1,196 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from cliff.command import Command
|
||||
from cliff.lister import Lister
|
||||
from cliff.show import ShowOne
|
||||
|
||||
import ara.cli.utils as cli_utils
|
||||
from ara.cli.base import global_arguments
|
||||
from ara.clients.utils import get_client
|
||||
|
||||
|
||||
class RecordList(Lister):
|
||||
""" Returns a list of records based on search queries """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(RecordList, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
# Record search arguments and ordering as per ara.api.filters.RecordFilter
|
||||
parser.add_argument(
|
||||
"--playbook",
|
||||
metavar="<playbook_id>",
|
||||
default=None,
|
||||
help=("List records for the specified playbook"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
metavar="<key>",
|
||||
default=None,
|
||||
help=("List records matching the specified key"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--long",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't truncate paths")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolve",
|
||||
action="store_true",
|
||||
default=os.environ.get("ARA_CLI_RESOLVE", False),
|
||||
help=("Resolve IDs to identifiers (such as path or names). Defaults to ARA_CLI_RESOLVE or False")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-updated",
|
||||
help=(
|
||||
"Orders records by a field ('id', 'created', 'updated', 'key')\n"
|
||||
"Defaults to '-updated' descending so the most recent record is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara record list --order=updated"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 50),
|
||||
help=("Returns the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 50.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.playbook is not None:
|
||||
query["playbook"] = args.playbook
|
||||
|
||||
if args.key is not None:
|
||||
query["key"] = args.key
|
||||
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
records = client.get("/api/v1/records", **query)
|
||||
if args.resolve:
|
||||
for record in records["results"]:
|
||||
playbook = cli_utils.get_playbook(client, record["playbook"])
|
||||
# Paths can easily take up too much width real estate
|
||||
if not args.long:
|
||||
record["playbook"] = "(%s) %s" % (playbook["id"], cli_utils.truncatepath(playbook["path"], 50))
|
||||
else:
|
||||
record["playbook"] = "(%s) %s" % (playbook["id"], playbook["path"])
|
||||
|
||||
columns = ("id", "key", "type", "playbook", "updated")
|
||||
# fmt: off
|
||||
return (
|
||||
columns, (
|
||||
[record[column] for column in columns]
|
||||
for record in records["results"]
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class RecordShow(ShowOne):
|
||||
""" Returns a detailed view of a specified record """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(RecordShow, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"record_id",
|
||||
metavar="<record-id>",
|
||||
help="Record to show",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
# TODO: Render json properly in pretty tables
|
||||
if args.formatter == "table":
|
||||
self.log.warn("Rendering using default table formatter, use '-f yaml' or '-f json' for improved display.")
|
||||
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
record = client.get("/api/v1/records/%s" % args.record_id)
|
||||
if "detail" in record and record["detail"] == "Not found.":
|
||||
self.log.error("Record not found: %s" % args.record_id)
|
||||
sys.exit(1)
|
||||
|
||||
playbook = "(%s) %s" % (record["playbook"]["id"], record["playbook"]["name"] or record["playbook"]["path"])
|
||||
record["report"] = "%s/playbooks/%s.html" % (args.server, record["playbook"]["id"])
|
||||
record["playbook"] = playbook
|
||||
|
||||
# fmt: off
|
||||
columns = (
|
||||
"id",
|
||||
"report",
|
||||
"playbook",
|
||||
"key",
|
||||
"value",
|
||||
"created",
|
||||
"updated",
|
||||
)
|
||||
# fmt: on
|
||||
return (columns, ([record[column] for column in columns]))
|
||||
|
||||
|
||||
class RecordDelete(Command):
|
||||
""" Deletes the specified record and associated resources """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(RecordDelete, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"record_id",
|
||||
metavar="<record-id>",
|
||||
help="Record to delete",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
client.delete("/api/v1/records/%s" % args.record_id)
|
@ -1,309 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from cliff.command import Command
|
||||
from cliff.lister import Lister
|
||||
from cliff.show import ShowOne
|
||||
|
||||
import ara.cli.utils as cli_utils
|
||||
from ara.cli.base import global_arguments
|
||||
from ara.clients.utils import get_client
|
||||
|
||||
|
||||
class ResultList(Lister):
|
||||
""" Returns a list of results based on search queries """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(ResultList, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
# Result search arguments and ordering as per ara.api.filters.ResultFilter
|
||||
# TODO: non-exhaustive (searching for failed, ok, unreachable, etc.)
|
||||
parser.add_argument(
|
||||
"--playbook",
|
||||
metavar="<playbook_id>",
|
||||
default=None,
|
||||
help=("List results for the specified playbook"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--play",
|
||||
metavar="<play_id>",
|
||||
default=None,
|
||||
help=("List results for the specified play"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task",
|
||||
metavar="<task_id>",
|
||||
default=None,
|
||||
help=("List results for the specified task"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
metavar="<host_id>",
|
||||
default=None,
|
||||
help=("List results for the specified host"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
metavar="<status>",
|
||||
default=None,
|
||||
help=(
|
||||
"List results matching a specific status:\n"
|
||||
"ok, failed, skipped, unreachable, changed, ignored, unknown"
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-errors",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Return only results with 'ignore_errors: true', defaults to false")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--changed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Return only changed results, defaults to false")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--long",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't truncate paths and include additional fields: changed, ignore_errors, play")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolve",
|
||||
action="store_true",
|
||||
default=os.environ.get("ARA_CLI_RESOLVE", False),
|
||||
help=("Resolve IDs to identifiers (such as path or names). Defaults to ARA_CLI_RESOLVE or False")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-started",
|
||||
help=(
|
||||
"Orders results by a field ('id', 'started', 'updated', 'ended', 'duration')\n"
|
||||
"Defaults to '-started' descending so the most recent result is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara result list --order=started"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 50),
|
||||
help=("Returns the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 50.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.playbook is not None:
|
||||
query["playbook"] = args.playbook
|
||||
if args.play is not None:
|
||||
query["play"] = args.play
|
||||
if args.task is not None:
|
||||
query["task"] = args.task
|
||||
if args.host is not None:
|
||||
query["host"] = args.host
|
||||
|
||||
if args.status is not None:
|
||||
query["status"] = args.status
|
||||
|
||||
if args.changed:
|
||||
query["changed"] = args.changed
|
||||
|
||||
query["ignore_errors"] = args.ignore_errors
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
results = client.get("/api/v1/results", **query)
|
||||
|
||||
if args.resolve:
|
||||
for result in results["results"]:
|
||||
playbook = cli_utils.get_playbook(client, result["playbook"])
|
||||
# Paths can easily take up too much width real estate
|
||||
if not args.long:
|
||||
result["playbook"] = "(%s) %s" % (playbook["id"], cli_utils.truncatepath(playbook["path"], 50))
|
||||
else:
|
||||
result["playbook"] = "(%s) %s" % (playbook["id"], playbook["path"])
|
||||
|
||||
task = cli_utils.get_task(client, result["task"])
|
||||
result["task"] = "(%s) %s" % (task["id"], task["name"])
|
||||
|
||||
host = cli_utils.get_host(client, result["host"])
|
||||
result["host"] = "(%s) %s" % (host["id"], host["name"])
|
||||
|
||||
if args.long:
|
||||
play = cli_utils.get_play(client, result["play"])
|
||||
result["play"] = "(%s) %s" % (play["id"], play["name"])
|
||||
|
||||
# fmt: off
|
||||
if args.long:
|
||||
columns = (
|
||||
"id",
|
||||
"status",
|
||||
"changed",
|
||||
"ignore_errors",
|
||||
"playbook",
|
||||
"play",
|
||||
"task",
|
||||
"host",
|
||||
"started",
|
||||
"duration",
|
||||
)
|
||||
else:
|
||||
columns = (
|
||||
"id",
|
||||
"status",
|
||||
"playbook",
|
||||
"task",
|
||||
"host",
|
||||
"started",
|
||||
"duration",
|
||||
)
|
||||
|
||||
return (
|
||||
columns, (
|
||||
[result[column] for column in columns]
|
||||
for result in results["results"]
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class ResultShow(ShowOne):
|
||||
""" Returns a detailed view of a specified result """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(ResultShow, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"result_id",
|
||||
metavar="<result-id>",
|
||||
help="Result to show",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--with-content",
|
||||
action="store_true",
|
||||
help="Also include the result content in the response (use with '-f json' or '-f yaml')"
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
# TODO: Render json properly in pretty tables
|
||||
if args.with_content and args.formatter == "table":
|
||||
self.log.warn("Rendering using default table formatter, use '-f yaml' or '-f json' for improved display.")
|
||||
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
result = client.get("/api/v1/results/%s" % args.result_id)
|
||||
if "detail" in result and result["detail"] == "Not found.":
|
||||
self.log.error("Result not found: %s" % args.result_id)
|
||||
sys.exit(1)
|
||||
|
||||
# Parse data from playbook and format it for display
|
||||
result["ansible_version"] = result["playbook"]["ansible_version"]
|
||||
playbook = "(%s) %s" % (result["playbook"]["id"], result["playbook"]["name"] or result["playbook"]["path"])
|
||||
result["report"] = "%s/playbooks/%s.html" % (args.server, result["playbook"]["id"])
|
||||
result["playbook"] = playbook
|
||||
|
||||
# Parse data from play and format it for display
|
||||
play = "(%s) %s" % (result["play"]["id"], result["play"]["name"])
|
||||
result["play"] = play
|
||||
|
||||
# Parse data from task and format it for display
|
||||
task = "(%s) %s" % (result["task"]["id"], result["task"]["name"])
|
||||
path = "(%s) %s:%s" % (result["task"]["file"], result["task"]["path"], result["task"]["lineno"])
|
||||
result["task"] = task
|
||||
result["path"] = path
|
||||
|
||||
if args.with_content:
|
||||
columns = (
|
||||
"id",
|
||||
"report",
|
||||
"status",
|
||||
"playbook",
|
||||
"play",
|
||||
"task",
|
||||
"path",
|
||||
"started",
|
||||
"ended",
|
||||
"duration",
|
||||
"ansible_version",
|
||||
"content",
|
||||
)
|
||||
else:
|
||||
columns = (
|
||||
"id",
|
||||
"report",
|
||||
"status",
|
||||
"playbook",
|
||||
"play",
|
||||
"task",
|
||||
"path",
|
||||
"started",
|
||||
"ended",
|
||||
"duration",
|
||||
"ansible_version",
|
||||
)
|
||||
return (columns, ([result[column] for column in columns]))
|
||||
|
||||
|
||||
class ResultDelete(Command):
|
||||
""" Deletes the specified result and associated resources """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(ResultDelete, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"result_id",
|
||||
metavar="<result-id>",
|
||||
help="Result to delete",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
client.delete("/api/v1/results/%s" % args.result_id)
|
426
ara/cli/task.py
426
ara/cli/task.py
@ -1,426 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from cliff.command import Command
|
||||
from cliff.lister import Lister
|
||||
from cliff.show import ShowOne
|
||||
|
||||
import ara.cli.utils as cli_utils
|
||||
from ara.cli.base import global_arguments
|
||||
from ara.clients.utils import get_client
|
||||
|
||||
|
||||
class TaskList(Lister):
|
||||
""" Returns a list of tasks based on search queries """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(TaskList, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
# Task search arguments and ordering as per ara.api.filters.TaskFilter
|
||||
parser.add_argument(
|
||||
"--playbook",
|
||||
metavar="<playbook_id>",
|
||||
default=None,
|
||||
help=("List tasks for a specified playbook id"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
metavar="<status>",
|
||||
default=None,
|
||||
help=("List tasks matching a specific status ('completed', 'running' or 'unknown')")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
metavar="<name>",
|
||||
default=None,
|
||||
help=("List tasks matching the provided name (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
metavar="<path>",
|
||||
default=None,
|
||||
help=("List tasks matching the provided path (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--action",
|
||||
metavar="<action>",
|
||||
default=None,
|
||||
help=("List tasks matching a specific action/ansible module (ex: 'debug', 'package', 'set_fact')"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--long",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't truncate paths and include additional fields: path, lineno, handler, play")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolve",
|
||||
action="store_true",
|
||||
default=os.environ.get("ARA_CLI_RESOLVE", False),
|
||||
help=("Resolve IDs to identifiers (such as path or names). Defaults to ARA_CLI_RESOLVE or False")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-started",
|
||||
help=(
|
||||
"Orders tasks by a field ('id', 'created', 'updated', 'started', 'ended', 'duration')\n"
|
||||
"Defaults to '-started' descending so the most recent task is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara task list --order=started"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 50),
|
||||
help=("Returns the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 50.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.playbook is not None:
|
||||
query["playbook"] = args.playbook
|
||||
|
||||
if args.status is not None:
|
||||
query["status"] = args.status
|
||||
|
||||
if args.name is not None:
|
||||
query["name"] = args.name
|
||||
|
||||
if args.path is not None:
|
||||
query["path"] = args.path
|
||||
|
||||
if args.action is not None:
|
||||
query["action"] = args.action
|
||||
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
tasks = client.get("/api/v1/tasks", **query)
|
||||
|
||||
for task in tasks["results"]:
|
||||
task["results"] = task["items"]["results"]
|
||||
if args.resolve:
|
||||
playbook = cli_utils.get_playbook(client, task["playbook"])
|
||||
# Paths can easily take up too much width real estate
|
||||
if not args.long:
|
||||
task["playbook"] = "(%s) %s" % (playbook["id"], cli_utils.truncatepath(playbook["path"], 50))
|
||||
else:
|
||||
task["playbook"] = "(%s) %s" % (playbook["id"], playbook["path"])
|
||||
|
||||
if args.long:
|
||||
play = cli_utils.get_play(client, task["play"])
|
||||
task["play"] = "(%s) %s" % (play["id"], play["name"])
|
||||
|
||||
# fmt: off
|
||||
if args.long:
|
||||
columns = (
|
||||
"id",
|
||||
"status",
|
||||
"results",
|
||||
"action",
|
||||
"name",
|
||||
"tags",
|
||||
"path",
|
||||
"lineno",
|
||||
"handler",
|
||||
"playbook",
|
||||
"play",
|
||||
"started",
|
||||
"duration"
|
||||
)
|
||||
else:
|
||||
columns = (
|
||||
"id",
|
||||
"status",
|
||||
"results",
|
||||
"action",
|
||||
"name",
|
||||
"playbook",
|
||||
"started",
|
||||
"duration"
|
||||
)
|
||||
# fmt: off
|
||||
return (
|
||||
columns, (
|
||||
[task[column] for column in columns]
|
||||
for task in tasks["results"]
|
||||
)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class TaskShow(ShowOne):
|
||||
""" Returns a detailed view of a specified task """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(TaskShow, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"task_id",
|
||||
metavar="<task-id>",
|
||||
help="Task to show",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
task = client.get("/api/v1/tasks/%s" % args.task_id)
|
||||
if "detail" in task and task["detail"] == "Not found.":
|
||||
self.log.error("Task not found: %s" % args.task_id)
|
||||
sys.exit(1)
|
||||
|
||||
task["report"] = "%s/playbooks/%s.html" % (args.server, task["playbook"]["id"])
|
||||
columns = (
|
||||
"id",
|
||||
"report",
|
||||
"name",
|
||||
"action",
|
||||
"status",
|
||||
"path",
|
||||
"lineno",
|
||||
"started",
|
||||
"ended",
|
||||
"duration",
|
||||
"tags",
|
||||
"handler",
|
||||
)
|
||||
return (columns, ([task[column] for column in columns]))
|
||||
|
||||
|
||||
class TaskDelete(Command):
|
||||
""" Deletes the specified task and associated resources """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(TaskDelete, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"task_id",
|
||||
metavar="<task-id>",
|
||||
help="Task to delete",
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
|
||||
# TODO: Improve client to be better at handling exceptions
|
||||
client.delete("/api/v1/tasks/%s" % args.task_id)
|
||||
|
||||
|
||||
class TaskMetrics(Lister):
|
||||
""" Provides metrics about actions in tasks """
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(TaskMetrics, self).get_parser(prog_name)
|
||||
parser = global_arguments(parser)
|
||||
# fmt: off
|
||||
parser.add_argument(
|
||||
"--aggregate",
|
||||
choices=["action", "name", "path"],
|
||||
default="action",
|
||||
help=("Aggregate tasks by action, name or path. Defaults to action."),
|
||||
)
|
||||
# Task search arguments and ordering as per ara.api.filters.TaskFilter
|
||||
parser.add_argument(
|
||||
"--playbook",
|
||||
metavar="<playbook_id>",
|
||||
default=None,
|
||||
help=("Filter for tasks for a specified playbook id"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
metavar="<status>",
|
||||
default=None,
|
||||
help=("Filter for tasks matching a specific status ('completed', 'expired', 'running' or 'unknown')")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
metavar="<name>",
|
||||
default=None,
|
||||
help=("Filter for tasks matching the provided name (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
metavar="<path>",
|
||||
default=None,
|
||||
help=("Filter for tasks matching the provided path (full or partial)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--action",
|
||||
metavar="<action>",
|
||||
default=None,
|
||||
help=("Filter for tasks matching a specific action/ansible module (ex: 'debug', 'package', 'set_fact')"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--long",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=("Don't truncate paths and include additional status fields: completed, running, expired, unknown")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--order",
|
||||
metavar="<order>",
|
||||
default="-started",
|
||||
help=(
|
||||
"Orders tasks by a field ('id', 'created', 'updated', 'started', 'ended', 'duration')\n"
|
||||
"Defaults to '-started' descending so the most recent task is at the top.\n"
|
||||
"The order can be reversed by omitting the '-': ara task metrics --order=started\n"
|
||||
"This influences the API request, not the ordering of the metrics."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
metavar="<limit>",
|
||||
default=os.environ.get("ARA_CLI_LIMIT", 1000),
|
||||
help=("Return metrics for the first <limit> determined by the ordering. Defaults to ARA_CLI_LIMIT or 1000.")
|
||||
)
|
||||
# fmt: on
|
||||
return parser
|
||||
|
||||
def take_action(self, args):
|
||||
client = get_client(
|
||||
client=args.client,
|
||||
endpoint=args.server,
|
||||
timeout=args.timeout,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
verify=False if args.insecure else True,
|
||||
run_sql_migrations=False,
|
||||
)
|
||||
query = {}
|
||||
if args.playbook is not None:
|
||||
query["playbook"] = args.playbook
|
||||
|
||||
if args.status is not None:
|
||||
query["status"] = args.status
|
||||
|
||||
if args.name is not None:
|
||||
query["name"] = args.name
|
||||
|
||||
if args.path is not None:
|
||||
query["path"] = args.path
|
||||
|
||||
if args.action is not None:
|
||||
query["action"] = args.action
|
||||
|
||||
query["order"] = args.order
|
||||
query["limit"] = args.limit
|
||||
|
||||
tasks = client.get("/api/v1/tasks", **query)
|
||||
|
||||
# TODO: This could probably be made more efficient without needing to iterate a second time
|
||||
# Group tasks by aggregate
|
||||
aggregate = {}
|
||||
for task in tasks["results"]:
|
||||
item = task[args.aggregate]
|
||||
if item not in aggregate:
|
||||
aggregate[item] = []
|
||||
aggregate[item].append(task)
|
||||
|
||||
data = {}
|
||||
for item, tasks in aggregate.items():
|
||||
data[item] = {
|
||||
"count": len(tasks),
|
||||
"results": 0,
|
||||
"expired": 0,
|
||||
"running": 0,
|
||||
"completed": 0,
|
||||
"unknown": 0,
|
||||
"duration_total": "00:00:00.000000",
|
||||
}
|
||||
|
||||
if args.aggregate == "path" and not args.long:
|
||||
data[item]["aggregate"] = cli_utils.truncatepath(item, 50)
|
||||
else:
|
||||
data[item]["aggregate"] = item
|
||||
|
||||
for task in tasks:
|
||||
for status in ["running", "completed", "expired", "unknown"]:
|
||||
if task["status"] == status:
|
||||
data[item][status] += 1
|
||||
|
||||
data[item]["results"] += task["items"]["results"]
|
||||
|
||||
if task["duration"] is not None:
|
||||
data[item]["duration_total"] = cli_utils.sum_timedelta(
|
||||
task["duration"], data[item]["duration_total"]
|
||||
)
|
||||
|
||||
data[item]["duration_avg"] = cli_utils.avg_timedelta(data[item]["duration_total"], data[item]["count"])
|
||||
|
||||
# fmt: off
|
||||
if args.long:
|
||||
columns = (
|
||||
"aggregate",
|
||||
"count",
|
||||
"results",
|
||||
"duration_total",
|
||||
"duration_avg",
|
||||
"completed",
|
||||
"running",
|
||||
"expired",
|
||||
"unknown",
|
||||
)
|
||||
else:
|
||||
columns = (
|
||||
"aggregate",
|
||||
"count",
|
||||
"results",
|
||||
"duration_total",
|
||||
"duration_avg",
|
||||
)
|
||||
|
||||
return (
|
||||
columns, (
|
||||
[data[action][column] for column in columns]
|
||||
for action in sorted(data.keys())
|
||||
)
|
||||
)
|
||||
# fmt: on
|
@ -1,86 +0,0 @@
|
||||
# Copyright (c) 2020 The ARA Records Ansible authors
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
import functools
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=10)
|
||||
def get_playbook(client, playbook_id):
|
||||
playbook = client.get("/api/v1/playbooks/%s" % playbook_id)
|
||||
return playbook
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=10)
|
||||
def get_play(client, play_id):
|
||||
play = client.get("/api/v1/plays/%s" % play_id)
|
||||
return play
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=10)
|
||||
def get_task(client, task_id):
|
||||
task = client.get("/api/v1/tasks/%s" % task_id)
|
||||
return task
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=10)
|
||||
def get_host(client, host_id):
|
||||
host = client.get("/api/v1/hosts/%s" % host_id)
|
||||
return host
|
||||
|
||||
|
||||
def parse_timedelta(string, pattern="%H:%M:%S.%f"):
|
||||
""" Parses a timedelta string back into a timedelta object """
|
||||
parsed = datetime.strptime(string, pattern)
|
||||
# fmt: off
|
||||
return timedelta(
|
||||
hours=parsed.hour,
|
||||
minutes=parsed.minute,
|
||||
seconds=parsed.second,
|
||||
microseconds=parsed.microsecond
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
def sum_timedelta(first, second):
|
||||
"""
|
||||
Returns the sum of two timedeltas as provided by the API, for example:
|
||||
00:00:02.031557 + 00:00:04.782581 = ?
|
||||
"""
|
||||
first = parse_timedelta(first)
|
||||
second = parse_timedelta(second)
|
||||
return str(first + second)
|
||||
|
||||
|
||||
def avg_timedelta(timedelta, count):
|
||||
""" Returns an average timedelta based on the amount of occurrences """
|
||||
timedelta = parse_timedelta(timedelta)
|
||||
return str(timedelta / count)
|
||||
|
||||
|
||||
# Also see: ui.templatetags.truncatepath
|
||||
def truncatepath(path, count):
|
||||
"""
|
||||
Truncates a path to less than 'count' characters.
|
||||
Paths are truncated on path separators.
|
||||
We prepend an ellipsis when we return a truncated path.
|
||||
"""
|
||||
try:
|
||||
length = int(count)
|
||||
except ValueError:
|
||||
return path
|
||||
|
||||
# Return immediately if there's nothing to truncate
|
||||
if len(path) < length:
|
||||
return path
|
||||
|
||||
dirname, basename = os.path.split(path)
|
||||
while dirname:
|
||||
if len(dirname) + len(basename) < length:
|
||||
break
|
||||
dirlist = dirname.split("/")
|
||||
dirlist.pop(0)
|
||||
dirname = "/".join(dirlist)
|
||||
|
||||
return "..." + os.path.join(dirname, basename)
|
@ -1,121 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA: Ansible Run Analysis.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# This is an "offline" API client that does not require standing up
|
||||
# an API server and does not execute actual HTTP calls.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import weakref
|
||||
|
||||
import pbr.version
|
||||
import requests
|
||||
|
||||
from ara.clients.utils import active_client
|
||||
|
||||
CLIENT_VERSION = pbr.version.VersionInfo("ara").release_string()
|
||||
|
||||
|
||||
class HttpClient(object):
|
||||
def __init__(self, endpoint="http://127.0.0.1:8000", auth=None, timeout=30, verify=True):
|
||||
self.log = logging.getLogger(__name__)
|
||||
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.auth = auth
|
||||
self.timeout = int(timeout)
|
||||
self.verify = verify
|
||||
self.headers = {
|
||||
"User-Agent": "ara-http-client_%s" % CLIENT_VERSION,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self.http = requests.Session()
|
||||
self.http.headers.update(self.headers)
|
||||
if self.auth is not None:
|
||||
self.http.auth = self.auth
|
||||
self.http.verify = self.verify
|
||||
|
||||
def _request(self, method, url, **payload):
|
||||
# Use requests.Session to do the query
|
||||
# The actual endpoint is:
|
||||
# <endpoint> <url>
|
||||
# http://127.0.0.1:8000 / api/v1/playbooks
|
||||
return self.http.request(method, self.endpoint + url, timeout=self.timeout, **payload)
|
||||
|
||||
def get(self, url, **payload):
|
||||
if payload:
|
||||
return self._request("get", url, **payload)
|
||||
else:
|
||||
return self._request("get", url)
|
||||
|
||||
def patch(self, url, **payload):
|
||||
return self._request("patch", url, data=json.dumps(payload))
|
||||
|
||||
def post(self, url, **payload):
|
||||
return self._request("post", url, data=json.dumps(payload))
|
||||
|
||||
def put(self, url, **payload):
|
||||
return self._request("put", url, data=json.dumps(payload))
|
||||
|
||||
def delete(self, url):
|
||||
return self._request("delete", url)
|
||||
|
||||
|
||||
class AraHttpClient(object):
|
||||
def __init__(self, endpoint="http://127.0.0.1:8000", auth=None, timeout=30, verify=True):
|
||||
self.log = logging.getLogger(__name__)
|
||||
self.endpoint = endpoint
|
||||
self.auth = auth
|
||||
self.timeout = int(timeout)
|
||||
self.verify = verify
|
||||
self.client = HttpClient(endpoint=self.endpoint, timeout=self.timeout, auth=self.auth, verify=self.verify)
|
||||
active_client._instance = weakref.ref(self)
|
||||
|
||||
def _request(self, method, url, **kwargs):
|
||||
func = getattr(self.client, method)
|
||||
if method == "delete":
|
||||
response = func(url)
|
||||
else:
|
||||
response = func(url, **kwargs)
|
||||
|
||||
if response.status_code >= 500:
|
||||
self.log.error("Failed to {method} on {url}: {content}".format(method=method, url=url, content=kwargs))
|
||||
|
||||
self.log.debug("HTTP {status}: {method} on {url}".format(status=response.status_code, method=method, url=url))
|
||||
|
||||
if response.status_code not in [200, 201, 204]:
|
||||
self.log.error("Failed to {method} on {url}: {content}".format(method=method, url=url, content=kwargs))
|
||||
|
||||
if response.status_code == 204:
|
||||
return response
|
||||
|
||||
return response.json()
|
||||
|
||||
def get(self, endpoint, **kwargs):
|
||||
return self._request("get", endpoint, params=kwargs)
|
||||
|
||||
def patch(self, endpoint, **kwargs):
|
||||
return self._request("patch", endpoint, **kwargs)
|
||||
|
||||
def post(self, endpoint, **kwargs):
|
||||
return self._request("post", endpoint, **kwargs)
|
||||
|
||||
def put(self, endpoint, **kwargs):
|
||||
return self._request("put", endpoint, **kwargs)
|
||||
|
||||
def delete(self, endpoint, **kwargs):
|
||||
return self._request("delete", endpoint)
|
@ -1,96 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA: Ansible Run Analysis.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# This is an "offline" API client that does not require standing up
|
||||
# an API server and does not execute actual HTTP calls.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from ara.clients.http import AraHttpClient
|
||||
from ara.setup.exceptions import MissingDjangoException
|
||||
|
||||
try:
|
||||
from django.core.handlers.wsgi import WSGIHandler
|
||||
from django.core.servers.basehttp import ThreadedWSGIServer, WSGIRequestHandler
|
||||
except ImportError as e:
|
||||
raise MissingDjangoException from e
|
||||
|
||||
|
||||
class AraOfflineClient(AraHttpClient):
|
||||
def __init__(self, auth=None, run_sql_migrations=True):
|
||||
self.log = logging.getLogger(__name__)
|
||||
|
||||
from django import setup as django_setup
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ara.server.settings")
|
||||
|
||||
if run_sql_migrations:
|
||||
# Automatically create the database and run migrations (is there a better way?)
|
||||
execute_from_command_line(["django", "migrate"])
|
||||
|
||||
# Set up the things Django needs
|
||||
django_setup()
|
||||
|
||||
self._start_server()
|
||||
super().__init__(endpoint="http://localhost:%d" % self.server_thread.port, auth=auth)
|
||||
|
||||
def _start_server(self):
|
||||
self.server_thread = ServerThread("localhost")
|
||||
self.server_thread.start()
|
||||
|
||||
# Wait for the live server to be ready
|
||||
self.server_thread.is_ready.wait()
|
||||
if self.server_thread.error:
|
||||
raise self.server_thread.error
|
||||
|
||||
|
||||
class ServerThread(threading.Thread):
|
||||
def __init__(self, host, port=0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.is_ready = threading.Event()
|
||||
self.error = None
|
||||
super().__init__(daemon=True)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Set up the live server and databases, and then loop over handling
|
||||
HTTP requests.
|
||||
"""
|
||||
try:
|
||||
# Create the handler for serving static and media files
|
||||
self.httpd = self._create_server()
|
||||
# If binding to port zero, assign the port allocated by the OS.
|
||||
if self.port == 0:
|
||||
self.port = self.httpd.server_address[1]
|
||||
self.httpd.set_app(WSGIHandler())
|
||||
self.is_ready.set()
|
||||
self.httpd.serve_forever()
|
||||
except Exception as e:
|
||||
self.error = e
|
||||
self.is_ready.set()
|
||||
|
||||
def _create_server(self):
|
||||
return ThreadedWSGIServer((self.host, self.port), QuietWSGIRequestHandler, allow_reuse_address=False)
|
||||
|
||||
|
||||
class QuietWSGIRequestHandler(WSGIRequestHandler):
|
||||
def log_message(*args):
|
||||
pass
|
@ -1,53 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
|
||||
def get_client(
|
||||
client="offline",
|
||||
endpoint="http://127.0.0.1:8000",
|
||||
timeout=30,
|
||||
username=None,
|
||||
password=None,
|
||||
verify=True,
|
||||
run_sql_migrations=True,
|
||||
):
|
||||
"""
|
||||
Returns a specified client configuration or one with sane defaults.
|
||||
"""
|
||||
auth = None
|
||||
if username is not None and password is not None:
|
||||
auth = HTTPBasicAuth(username, password)
|
||||
|
||||
if client == "offline":
|
||||
from ara.clients.offline import AraOfflineClient
|
||||
|
||||
return AraOfflineClient(auth=auth, run_sql_migrations=run_sql_migrations)
|
||||
elif client == "http":
|
||||
from ara.clients.http import AraHttpClient
|
||||
|
||||
return AraHttpClient(endpoint=endpoint, timeout=timeout, auth=auth, verify=verify)
|
||||
else:
|
||||
raise ValueError("Unsupported API client: %s (use 'http' or 'offline')" % client)
|
||||
|
||||
|
||||
def active_client():
|
||||
return active_client._instance()
|
||||
|
||||
|
||||
active_client._instance = None
|
@ -1,102 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA: Ansible Run Analysis.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from ansible.playbook.play import Play
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
from ara.clients import utils as client_utils
|
||||
|
||||
DOCUMENTATION = """
|
||||
---
|
||||
module: ara_playbook
|
||||
short_description: Retrieves either a specific playbook from ARA or the one currently running
|
||||
version_added: "2.9"
|
||||
author: "David Moreau-Simard <dmsimard@redhat.com>"
|
||||
description:
|
||||
- Retrieves either a specific playbook from ARA or the one currently running
|
||||
options:
|
||||
playbook_id:
|
||||
description:
|
||||
- id of the playbook to retrieve
|
||||
- if not set, the module will use the ongoing playbook's id
|
||||
required: false
|
||||
|
||||
requirements:
|
||||
- "python >= 3.5"
|
||||
- "ara >= 1.4.0"
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: Get a specific playbook
|
||||
ara_playbook:
|
||||
playbook_id: 5
|
||||
register: playbook_query
|
||||
|
||||
- name: Get current playbook by not specifying a playbook id
|
||||
ara_playbook:
|
||||
register: playbook_query
|
||||
|
||||
- name: Do something with the playbook
|
||||
debug:
|
||||
msg: "Playbook report: http://ara_api/playbook/{{ playbook_query.playbook.id | string }}.html"
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
playbook:
|
||||
description: playbook object returned by the API
|
||||
returned: on success
|
||||
type: dict
|
||||
"""
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
""" Retrieves either a specific playbook from ARA or the one currently running """
|
||||
|
||||
TRANSFERS_FILES = False
|
||||
VALID_ARGS = frozenset(("playbook_id"))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ActionModule, self).__init__(*args, **kwargs)
|
||||
self.client = client_utils.active_client()
|
||||
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
if task_vars is None:
|
||||
task_vars = dict()
|
||||
|
||||
for arg in self._task.args:
|
||||
if arg not in self.VALID_ARGS:
|
||||
result = {"failed": True, "msg": "{0} is not a valid option.".format(arg)}
|
||||
return result
|
||||
|
||||
result = super(ActionModule, self).run(tmp, task_vars)
|
||||
|
||||
playbook_id = self._task.args.get("playbook_id", None)
|
||||
if playbook_id is None:
|
||||
# Retrieve the playbook id by working our way up from the task to find
|
||||
# the play uuid. Once we have the play uuid, we can find the playbook.
|
||||
parent = self._task
|
||||
while not isinstance(parent._parent._play, Play):
|
||||
parent = parent._parent
|
||||
|
||||
play = self.client.get("/api/v1/plays?uuid=%s" % parent._parent._play._uuid)
|
||||
playbook_id = play["results"][0]["playbook"]
|
||||
|
||||
result["playbook"] = self.client.get("/api/v1/playbooks/%s" % playbook_id)
|
||||
result["changed"] = False
|
||||
result["msg"] = "Queried playbook %s from ARA" % playbook_id
|
||||
|
||||
return result
|
@ -1,218 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA: Ansible Run Analysis.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from ansible.playbook.play import Play
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
from ara.clients import utils as client_utils
|
||||
|
||||
DOCUMENTATION = """
|
||||
---
|
||||
module: ara_record
|
||||
short_description: Ansible module to record persistent data with ARA.
|
||||
version_added: "2.0"
|
||||
author: "David Moreau-Simard <dmsimard@redhat.com>"
|
||||
description:
|
||||
- Ansible module to record persistent data with ARA.
|
||||
options:
|
||||
playbook_id:
|
||||
description:
|
||||
- id of the playbook to write the key to
|
||||
- if not set, the module will use the ongoing playbook's id
|
||||
required: false
|
||||
key:
|
||||
description:
|
||||
- Name of the key to write data to
|
||||
required: true
|
||||
value:
|
||||
description:
|
||||
- Value of the key written to
|
||||
required: true
|
||||
type:
|
||||
description:
|
||||
- Type of the key
|
||||
choices: [text, url, json, list, dict]
|
||||
default: text
|
||||
|
||||
requirements:
|
||||
- "python >= 3.5"
|
||||
- "ara >= 1.0.0"
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- name: Associate specific data to a key for a playbook
|
||||
ara_record:
|
||||
key: "foo"
|
||||
value: "bar"
|
||||
|
||||
- name: Associate data to a playbook that previously ran
|
||||
ara_record:
|
||||
playbook_id: 21
|
||||
key: logs
|
||||
value: "{{ lookup('file', '/var/log/ansible.log') }}"
|
||||
type: text
|
||||
|
||||
- name: Retrieve the git version of the development repository
|
||||
shell: cd dev && git rev-parse HEAD
|
||||
register: git_version
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Record and register the git version of the playbooks
|
||||
ara_record:
|
||||
key: "git_version"
|
||||
value: "{{ git_version.stdout }}"
|
||||
register: version
|
||||
|
||||
- name: Print recorded data
|
||||
debug:
|
||||
msg: "{{ version.playbook_id }} - {{ version.key }}: {{ version.value }}
|
||||
|
||||
# Write data with a type (otherwise defaults to "text")
|
||||
# This changes the behavior on how the value is presented in the web interface
|
||||
- name: Record different formats of things
|
||||
ara_record:
|
||||
key: "{{ item.key }}"
|
||||
value: "{{ item.value }}"
|
||||
type: "{{ item.type }}"
|
||||
with_items:
|
||||
- { key: "log", value: "error", type: "text" }
|
||||
- { key: "website", value: "http://domain.tld", type: "url" }
|
||||
- { key: "data", value: "{ 'key': 'value' }", type: "json" }
|
||||
- { key: "somelist", value: ['one', 'two'], type: "list" }
|
||||
- { key: "somedict", value: {'key': 'value' }, type: "dict" }
|
||||
"""
|
||||
|
||||
|
||||
RETURN = """
|
||||
playbook:
|
||||
description: ID of the playbook the data was recorded in
|
||||
returned: on success
|
||||
type: int
|
||||
sample: 1
|
||||
key:
|
||||
description: Key where the record is saved
|
||||
returned: on success
|
||||
type: str
|
||||
sample: log_url
|
||||
value:
|
||||
description: Value of the key
|
||||
returned: on success
|
||||
type: complex
|
||||
sample: http://logurl
|
||||
type:
|
||||
description: Type of the key
|
||||
returned: on success
|
||||
type: string
|
||||
sample: url
|
||||
created:
|
||||
description: Date the record was created (ISO-8601)
|
||||
returned: on success
|
||||
type: str
|
||||
sample: 2018-11-15T17:27:41.597234Z
|
||||
updated:
|
||||
description: Date the record was updated (ISO-8601)
|
||||
returned: on success
|
||||
type: str
|
||||
sample: 2018-11-15T17:27:41.597265Z
|
||||
"""
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
""" Record persistent data as key/value pairs in ARA """
|
||||
|
||||
TRANSFERS_FILES = False
|
||||
VALID_ARGS = frozenset(("playbook_id", "key", "value", "type"))
|
||||
VALID_TYPES = ["text", "url", "json", "list", "dict"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ActionModule, self).__init__(*args, **kwargs)
|
||||
self.client = client_utils.active_client()
|
||||
|
||||
def create_or_update_key(self, playbook, key, value, type):
|
||||
changed = False
|
||||
record = self.client.get("/api/v1/records?playbook=%s&key=%s" % (playbook, key))
|
||||
if record["count"] == 0:
|
||||
# Create the record if it doesn't exist
|
||||
record = self.client.post("/api/v1/records", playbook=playbook, key=key, value=value, type=type)
|
||||
changed = True
|
||||
else:
|
||||
# Otherwise update it if the data is different (idempotency)
|
||||
old = self.client.get("/api/v1/records/%s" % record["results"][0]["id"])
|
||||
if old["value"] != value or old["type"] != type:
|
||||
record = self.client.patch("/api/v1/records/%s" % old["id"], key=key, value=value, type=type)
|
||||
changed = True
|
||||
else:
|
||||
record = old
|
||||
return record, changed
|
||||
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
if task_vars is None:
|
||||
task_vars = dict()
|
||||
|
||||
for arg in self._task.args:
|
||||
if arg not in self.VALID_ARGS:
|
||||
result = {"failed": True, "msg": "{0} is not a valid option.".format(arg)}
|
||||
return result
|
||||
|
||||
result = super(ActionModule, self).run(tmp, task_vars)
|
||||
|
||||
playbook_id = self._task.args.get("playbook_id", None)
|
||||
key = self._task.args.get("key", None)
|
||||
value = self._task.args.get("value", None)
|
||||
type = self._task.args.get("type", "text")
|
||||
|
||||
required = ["key", "value"]
|
||||
for parameter in required:
|
||||
if not self._task.args.get(parameter):
|
||||
result["failed"] = True
|
||||
result["msg"] = "Parameter '{0}' is required".format(parameter)
|
||||
return result
|
||||
|
||||
if type not in self.VALID_TYPES:
|
||||
result["failed"] = True
|
||||
msg = "Type '{0}' is not supported, choose one of: {1}".format(type, ", ".join(self.VALID_TYPES))
|
||||
result["msg"] = msg
|
||||
return result
|
||||
|
||||
if playbook_id is None:
|
||||
# Retrieve the playbook id by working our way up from the task to find
|
||||
# the play uuid. Once we have the play uuid, we can find the playbook.
|
||||
parent = self._task
|
||||
while not isinstance(parent._parent._play, Play):
|
||||
parent = parent._parent
|
||||
|
||||
play = self.client.get("/api/v1/plays?uuid=%s" % parent._parent._play._uuid)
|
||||
playbook_id = play["results"][0]["playbook"]
|
||||
|
||||
try:
|
||||
data, changed = self.create_or_update_key(playbook_id, key, value, type)
|
||||
result["changed"] = changed
|
||||
result["key"] = data["key"]
|
||||
result["value"] = data["value"]
|
||||
result["type"] = data["type"]
|
||||
result["playbook_id"] = data["playbook"]
|
||||
result["created"] = data["created"]
|
||||
result["updated"] = data["updated"]
|
||||
if result["changed"]:
|
||||
result["msg"] = "Record created or updated in ARA"
|
||||
else:
|
||||
result["msg"] = "Record unchanged in ARA"
|
||||
except Exception as e:
|
||||
result["changed"] = False
|
||||
result["failed"] = True
|
||||
result["msg"] = "Record failed to be created or updated in ARA: {0}".format(str(e))
|
||||
return result
|
@ -1,559 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA: Ansible Run Analysis.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from ansible import __version__ as ansible_version
|
||||
from ansible.parsing.ajson import AnsibleJSONEncoder
|
||||
from ansible.plugins.callback import CallbackBase
|
||||
from ansible.vars.clean import module_response_deepcopy, strip_internal_keys
|
||||
|
||||
from ara.clients import utils as client_utils
|
||||
|
||||
# Ansible CLI options are now in ansible.context in >= 2.8
|
||||
# https://github.com/ansible/ansible/commit/afdbb0d9d5bebb91f632f0d4a1364de5393ba17a
|
||||
try:
|
||||
from ansible import context
|
||||
|
||||
cli_options = {key: value for key, value in context.CLIARGS.items()}
|
||||
except ImportError:
|
||||
# < 2.8 doesn't have ansible.context
|
||||
try:
|
||||
from __main__ import cli
|
||||
|
||||
cli_options = cli.options.__dict__
|
||||
except ImportError:
|
||||
# using API without CLI
|
||||
cli_options = {}
|
||||
|
||||
|
||||
DOCUMENTATION = """
|
||||
callback: ara
|
||||
callback_type: notification
|
||||
requirements:
|
||||
- ara
|
||||
short_description: Sends playbook execution data to the ARA API internally or over HTTP
|
||||
description:
|
||||
- Sends playbook execution data to the ARA API internally or over HTTP
|
||||
options:
|
||||
api_client:
|
||||
description: The client to use for communicating with the API
|
||||
default: offline
|
||||
env:
|
||||
- name: ARA_API_CLIENT
|
||||
ini:
|
||||
- section: ara
|
||||
key: api_client
|
||||
choices: ['offline', 'http']
|
||||
api_server:
|
||||
description: When using the HTTP client, the base URL to the ARA API server
|
||||
default: http://127.0.0.1:8000
|
||||
env:
|
||||
- name: ARA_API_SERVER
|
||||
ini:
|
||||
- section: ara
|
||||
key: api_server
|
||||
api_username:
|
||||
description: If authentication is required, the username to authenticate with
|
||||
default: null
|
||||
env:
|
||||
- name: ARA_API_USERNAME
|
||||
ini:
|
||||
- section: ara
|
||||
key: api_username
|
||||
api_password:
|
||||
description: If authentication is required, the password to authenticate with
|
||||
default: null
|
||||
env:
|
||||
- name: ARA_API_PASSWORD
|
||||
ini:
|
||||
- section: ara
|
||||
key: api_password
|
||||
api_insecure:
|
||||
description: Can be enabled to ignore SSL certification of the API server
|
||||
type: bool
|
||||
default: false
|
||||
env:
|
||||
- name: ARA_API_INSECURE
|
||||
ini:
|
||||
- section: ara
|
||||
key: api_insecure
|
||||
api_timeout:
|
||||
description: Timeout, in seconds, before giving up on HTTP requests
|
||||
type: integer
|
||||
default: 30
|
||||
env:
|
||||
- name: ARA_API_TIMEOUT
|
||||
ini:
|
||||
- section: ara
|
||||
key: api_timeout
|
||||
argument_labels:
|
||||
description: |
|
||||
A list of CLI arguments that, if set, will be automatically applied to playbooks as labels.
|
||||
Note that CLI arguments are not always named the same as how they are represented by Ansible.
|
||||
For example, --limit is "subset", --user is "remote_user" but --check is "check".
|
||||
type: list
|
||||
default:
|
||||
- remote_user
|
||||
- check
|
||||
- tags
|
||||
- skip_tags
|
||||
- subset
|
||||
env:
|
||||
- name: ARA_ARGUMENT_LABELS
|
||||
ini:
|
||||
- section: ara
|
||||
key: argument_labels
|
||||
default_labels:
|
||||
description: A list of default labels that will be applied to playbooks
|
||||
type: list
|
||||
default: []
|
||||
env:
|
||||
- name: ARA_DEFAULT_LABELS
|
||||
ini:
|
||||
- section: ara
|
||||
key: default_labels
|
||||
ignored_facts:
|
||||
description: List of host facts that will not be saved by ARA
|
||||
type: list
|
||||
default: ["ansible_env"]
|
||||
env:
|
||||
- name: ARA_IGNORED_FACTS
|
||||
ini:
|
||||
- section: ara
|
||||
key: ignored_facts
|
||||
ignored_arguments:
|
||||
description: List of Ansible arguments that will not be saved by ARA
|
||||
type: list
|
||||
default: ["extra_vars"]
|
||||
env:
|
||||
- name: ARA_IGNORED_ARGUMENTS
|
||||
ini:
|
||||
- section: ara
|
||||
key: ignored_arguments
|
||||
ignored_files:
|
||||
description: List of patterns that will not be saved by ARA
|
||||
type: list
|
||||
default: []
|
||||
env:
|
||||
- name: ARA_IGNORED_FILES
|
||||
ini:
|
||||
- section: ara
|
||||
key: ignored_files
|
||||
callback_threads:
|
||||
description:
|
||||
- The number of threads to use in API client thread pools
|
||||
- When set to 0, no threading will be used (default) which is appropriate for usage with sqlite
|
||||
- Using threads is recommended when the server is using MySQL or PostgreSQL
|
||||
type: integer
|
||||
default: 0
|
||||
env:
|
||||
- name: ARA_CALLBACK_THREADS
|
||||
ini:
|
||||
- section: ara
|
||||
key: callback_threads
|
||||
"""
|
||||
|
||||
|
||||
class CallbackModule(CallbackBase):
|
||||
"""
|
||||
Saves data from an Ansible run into a database
|
||||
"""
|
||||
|
||||
CALLBACK_VERSION = 2.0
|
||||
CALLBACK_TYPE = "awesome"
|
||||
CALLBACK_NAME = "ara_default"
|
||||
|
||||
def __init__(self):
|
||||
super(CallbackModule, self).__init__()
|
||||
self.log = logging.getLogger("ara.plugins.callback.default")
|
||||
# These are configured in self.set_options
|
||||
self.client = None
|
||||
self.callback_threads = None
|
||||
|
||||
self.ignored_facts = []
|
||||
self.ignored_arguments = []
|
||||
self.ignored_files = []
|
||||
|
||||
self.result = None
|
||||
self.result_started = {}
|
||||
self.result_ended = {}
|
||||
self.task = None
|
||||
self.play = None
|
||||
self.playbook = None
|
||||
self.stats = None
|
||||
self.file_cache = {}
|
||||
self.host_cache = {}
|
||||
|
||||
def set_options(self, task_keys=None, var_options=None, direct=None):
|
||||
super(CallbackModule, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct)
|
||||
|
||||
self.argument_labels = self.get_option("argument_labels")
|
||||
self.default_labels = self.get_option("default_labels")
|
||||
self.ignored_facts = self.get_option("ignored_facts")
|
||||
self.ignored_arguments = self.get_option("ignored_arguments")
|
||||
self.ignored_files = self.get_option("ignored_files")
|
||||
|
||||
client = self.get_option("api_client")
|
||||
endpoint = self.get_option("api_server")
|
||||
timeout = self.get_option("api_timeout")
|
||||
username = self.get_option("api_username")
|
||||
password = self.get_option("api_password")
|
||||
insecure = self.get_option("api_insecure")
|
||||
self.client = client_utils.get_client(
|
||||
client=client,
|
||||
endpoint=endpoint,
|
||||
timeout=timeout,
|
||||
username=username,
|
||||
password=password,
|
||||
verify=False if insecure else True,
|
||||
)
|
||||
|
||||
# TODO: Consider un-hardcoding this and plumbing pool_maxsize to requests.adapters.HTTPAdapter.
|
||||
# In the meantime default to 4 so we don't go above requests.adapters.DEFAULT_POOLSIZE.
|
||||
# Otherwise we can hit "urllib3.connectionpool: Connection pool is full"
|
||||
self.callback_threads = self.get_option("callback_threads")
|
||||
if self.callback_threads > 4:
|
||||
self.callback_threads = 4
|
||||
|
||||
def _submit_thread(self, threadpool, func, *args, **kwargs):
|
||||
# Manages whether or not the function should be threaded to keep things DRY
|
||||
if self.callback_threads:
|
||||
# Pick from one of two thread pools (global or task)
|
||||
threads = getattr(self, threadpool + "_threads")
|
||||
threads.submit(func, *args, **kwargs)
|
||||
else:
|
||||
func(*args, **kwargs)
|
||||
|
||||
def v2_playbook_on_start(self, playbook):
|
||||
self.log.debug("v2_playbook_on_start")
|
||||
|
||||
if self.callback_threads:
|
||||
self.global_threads = ThreadPoolExecutor(max_workers=self.callback_threads)
|
||||
self.log.debug("Global thread pool initialized with %s thread(s)" % self.callback_threads)
|
||||
|
||||
content = None
|
||||
|
||||
if playbook._file_name == "__adhoc_playbook__":
|
||||
content = cli_options["module_name"]
|
||||
if cli_options["module_args"]:
|
||||
content = "{0}: {1}".format(content, cli_options["module_args"])
|
||||
path = "Ad-Hoc: {0}".format(content)
|
||||
else:
|
||||
path = os.path.abspath(playbook._file_name)
|
||||
|
||||
# Potentially sanitize some user-specified keys
|
||||
for argument in self.ignored_arguments:
|
||||
if argument in cli_options:
|
||||
self.log.debug("Ignoring argument: %s" % argument)
|
||||
cli_options[argument] = "Not saved by ARA as configured by 'ignored_arguments'"
|
||||
|
||||
# Retrieve and format CLI options for argument labels
|
||||
argument_labels = []
|
||||
for label in self.argument_labels:
|
||||
if label in cli_options:
|
||||
# Some arguments are lists or tuples
|
||||
if isinstance(cli_options[label], tuple) or isinstance(cli_options[label], list):
|
||||
# Only label these if they're not empty
|
||||
if cli_options[label]:
|
||||
argument_labels.append("%s:%s" % (label, ",".join(cli_options[label])))
|
||||
# Some arguments are booleans
|
||||
elif isinstance(cli_options[label], bool):
|
||||
argument_labels.append("%s:%s" % (label, cli_options[label]))
|
||||
# The rest can be printed as-is if there is something set
|
||||
elif cli_options[label]:
|
||||
argument_labels.append("%s:%s" % (label, cli_options[label]))
|
||||
self.argument_labels = argument_labels
|
||||
|
||||
# Create the playbook
|
||||
self.playbook = self.client.post(
|
||||
"/api/v1/playbooks",
|
||||
ansible_version=ansible_version,
|
||||
arguments=cli_options,
|
||||
status="running",
|
||||
path=path,
|
||||
controller=socket.getfqdn(),
|
||||
started=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
# Record the playbook file
|
||||
self._submit_thread("global", self._get_or_create_file, path, content)
|
||||
|
||||
return self.playbook
|
||||
|
||||
def v2_playbook_on_play_start(self, play):
|
||||
self.log.debug("v2_playbook_on_play_start")
|
||||
self._end_task()
|
||||
self._end_play()
|
||||
|
||||
# Load variables to verify if there is anything relevant for ara
|
||||
play_vars = play._variable_manager.get_vars(play=play)["vars"]
|
||||
if "ara_playbook_name" in play_vars:
|
||||
self._submit_thread("global", self._set_playbook_name, play_vars["ara_playbook_name"])
|
||||
|
||||
labels = self.default_labels + self.argument_labels
|
||||
if "ara_playbook_labels" in play_vars:
|
||||
# ara_playbook_labels can be supplied as a list inside a playbook
|
||||
# but it might also be specified as a comma separated string when
|
||||
# using extra-vars
|
||||
if isinstance(play_vars["ara_playbook_labels"], list):
|
||||
labels.extend(play_vars["ara_playbook_labels"])
|
||||
elif isinstance(play_vars["ara_playbook_labels"], str):
|
||||
labels.extend(play_vars["ara_playbook_labels"].split(","))
|
||||
else:
|
||||
raise TypeError("ara_playbook_labels must be a list or a comma-separated string")
|
||||
if labels:
|
||||
self._submit_thread("global", self._set_playbook_labels, labels)
|
||||
|
||||
# Record all the files involved in the play
|
||||
for path in play._loader._FILE_CACHE.keys():
|
||||
self._submit_thread("global", self._get_or_create_file, path)
|
||||
|
||||
# Create the play
|
||||
self.play = self.client.post(
|
||||
"/api/v1/plays",
|
||||
name=play.name,
|
||||
status="running",
|
||||
uuid=play._uuid,
|
||||
playbook=self.playbook["id"],
|
||||
started=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
return self.play
|
||||
|
||||
def v2_playbook_on_handler_task_start(self, task):
|
||||
self.log.debug("v2_playbook_on_handler_task_start")
|
||||
# TODO: Why doesn't `v2_playbook_on_handler_task_start` have is_conditional ?
|
||||
self.v2_playbook_on_task_start(task, False, handler=True)
|
||||
|
||||
def v2_playbook_on_task_start(self, task, is_conditional, handler=False):
|
||||
self.log.debug("v2_playbook_on_task_start")
|
||||
self._end_task()
|
||||
|
||||
if self.callback_threads:
|
||||
self.task_threads = ThreadPoolExecutor(max_workers=self.callback_threads)
|
||||
self.log.debug("Task thread pool initialized with %s thread(s)" % self.callback_threads)
|
||||
|
||||
pathspec = task.get_path()
|
||||
if pathspec:
|
||||
path, lineno = pathspec.split(":", 1)
|
||||
lineno = int(lineno)
|
||||
else:
|
||||
# Task doesn't have a path, default to "something"
|
||||
path = self.playbook["path"]
|
||||
lineno = 1
|
||||
|
||||
# Get task file
|
||||
task_file = self._get_or_create_file(path)
|
||||
|
||||
self.task = self.client.post(
|
||||
"/api/v1/tasks",
|
||||
name=task.get_name(),
|
||||
status="running",
|
||||
action=task.action,
|
||||
play=self.play["id"],
|
||||
playbook=self.playbook["id"],
|
||||
file=task_file["id"],
|
||||
tags=task.tags,
|
||||
lineno=lineno,
|
||||
handler=handler,
|
||||
started=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
return self.task
|
||||
|
||||
def v2_runner_on_start(self, host, task):
|
||||
# v2_runner_on_start was added in 2.8 so this doesn't get run for Ansible 2.7 and below.
|
||||
self.result_started[host.get_name()] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
def v2_runner_on_ok(self, result, **kwargs):
|
||||
self._submit_thread("task", self._load_result, result, "ok", **kwargs)
|
||||
|
||||
def v2_runner_on_unreachable(self, result, **kwargs):
|
||||
self._submit_thread("task", self._load_result, result, "unreachable", **kwargs)
|
||||
|
||||
def v2_runner_on_failed(self, result, **kwargs):
|
||||
self._submit_thread("task", self._load_result, result, "failed", **kwargs)
|
||||
|
||||
def v2_runner_on_skipped(self, result, **kwargs):
|
||||
self._submit_thread("task", self._load_result, result, "skipped", **kwargs)
|
||||
|
||||
def v2_playbook_on_stats(self, stats):
|
||||
self.log.debug("v2_playbook_on_stats")
|
||||
self._end_task()
|
||||
self._end_play()
|
||||
self._load_stats(stats)
|
||||
self._end_playbook(stats)
|
||||
|
||||
def _end_task(self):
|
||||
if self.task is not None:
|
||||
self._submit_thread(
|
||||
"task",
|
||||
self.client.patch,
|
||||
"/api/v1/tasks/%s" % self.task["id"],
|
||||
status="completed",
|
||||
ended=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
)
|
||||
if self.callback_threads:
|
||||
# Flush threads before moving on to next task to make sure all results are saved
|
||||
self.log.debug("waiting for task threads...")
|
||||
self.task_threads.shutdown(wait=True)
|
||||
self.task_threads = None
|
||||
self.task = None
|
||||
|
||||
def _end_play(self):
|
||||
if self.play is not None:
|
||||
self._submit_thread(
|
||||
"global",
|
||||
self.client.patch,
|
||||
"/api/v1/plays/%s" % self.play["id"],
|
||||
status="completed",
|
||||
ended=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
)
|
||||
self.play = None
|
||||
|
||||
def _end_playbook(self, stats):
|
||||
status = "unknown"
|
||||
if len(stats.failures) >= 1 or len(stats.dark) >= 1:
|
||||
status = "failed"
|
||||
else:
|
||||
status = "completed"
|
||||
|
||||
self._submit_thread(
|
||||
"global",
|
||||
self.client.patch,
|
||||
"/api/v1/playbooks/%s" % self.playbook["id"],
|
||||
status=status,
|
||||
ended=datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
if self.callback_threads:
|
||||
self.log.debug("waiting for global threads...")
|
||||
self.global_threads.shutdown(wait=True)
|
||||
|
||||
def _set_playbook_name(self, name):
|
||||
if self.playbook["name"] != name:
|
||||
self.playbook = self.client.patch("/api/v1/playbooks/%s" % self.playbook["id"], name=name)
|
||||
|
||||
def _set_playbook_labels(self, labels):
|
||||
# Only update labels if our cache doesn't match
|
||||
current_labels = [label["name"] for label in self.playbook["labels"]]
|
||||
if sorted(current_labels) != sorted(labels):
|
||||
self.log.debug("Updating playbook labels to match: %s" % ",".join(labels))
|
||||
self.playbook = self.client.patch("/api/v1/playbooks/%s" % self.playbook["id"], labels=labels)
|
||||
|
||||
def _get_or_create_file(self, path, content=None):
|
||||
if path not in self.file_cache:
|
||||
self.log.debug("File not in cache, getting or creating: %s" % path)
|
||||
for ignored_file_pattern in self.ignored_files:
|
||||
if ignored_file_pattern in path:
|
||||
self.log.debug("Ignoring file {1}, matched pattern: {0}".format(ignored_file_pattern, path))
|
||||
content = "Not saved by ARA as configured by 'ignored_files'"
|
||||
if content is None:
|
||||
try:
|
||||
with open(path, "r") as fd:
|
||||
content = fd.read()
|
||||
except IOError as e:
|
||||
self.log.error("Unable to open {0} for reading: {1}".format(path, str(e)))
|
||||
content = """ARA was not able to read this file successfully.
|
||||
Refer to the logs for more information"""
|
||||
|
||||
self.file_cache[path] = self.client.post(
|
||||
"/api/v1/files", playbook=self.playbook["id"], path=path, content=content
|
||||
)
|
||||
|
||||
return self.file_cache[path]
|
||||
|
||||
def _get_or_create_host(self, host):
|
||||
# Note: The get_or_create is handled through the serializer of the API server.
|
||||
if host not in self.host_cache:
|
||||
self.log.debug("Host not in cache, getting or creating: %s" % host)
|
||||
self.host_cache[host] = self.client.post("/api/v1/hosts", name=host, playbook=self.playbook["id"])
|
||||
return self.host_cache[host]
|
||||
|
||||
def _load_result(self, result, status, **kwargs):
|
||||
"""
|
||||
This method is called when an individual task instance on a single
|
||||
host completes. It is responsible for logging a single result to the
|
||||
database.
|
||||
"""
|
||||
hostname = result._host.get_name()
|
||||
self.result_ended[hostname] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
# Retrieve the host so we can associate the result to the host id
|
||||
host = self._get_or_create_host(hostname)
|
||||
|
||||
results = strip_internal_keys(module_response_deepcopy(result._result))
|
||||
|
||||
# Round-trip through JSON to sort keys and convert Ansible types
|
||||
# to standard types
|
||||
try:
|
||||
jsonified = json.dumps(results, cls=AnsibleJSONEncoder, ensure_ascii=False, sort_keys=True)
|
||||
except TypeError:
|
||||
# Python 3 can't sort non-homogenous keys.
|
||||
# https://bugs.python.org/issue25457
|
||||
jsonified = json.dumps(results, cls=AnsibleJSONEncoder, ensure_ascii=False, sort_keys=False)
|
||||
results = json.loads(jsonified)
|
||||
|
||||
# Sanitize facts
|
||||
if "ansible_facts" in results:
|
||||
for fact in self.ignored_facts:
|
||||
if fact in results["ansible_facts"]:
|
||||
self.log.debug("Ignoring fact: %s" % fact)
|
||||
results["ansible_facts"][fact] = "Not saved by ARA as configured by 'ignored_facts'"
|
||||
|
||||
self.result = self.client.post(
|
||||
"/api/v1/results",
|
||||
playbook=self.playbook["id"],
|
||||
task=self.task["id"],
|
||||
host=host["id"],
|
||||
play=self.task["play"],
|
||||
content=results,
|
||||
status=status,
|
||||
started=self.result_started[hostname] if hostname in self.result_started else self.task["started"],
|
||||
ended=self.result_ended[hostname],
|
||||
changed=result._result.get("changed", False),
|
||||
# Note: ignore_errors might be None instead of a boolean
|
||||
ignore_errors=kwargs.get("ignore_errors", False) or False,
|
||||
)
|
||||
|
||||
if self.task["action"] in ["setup", "gather_facts"] and "ansible_facts" in results:
|
||||
self.client.patch("/api/v1/hosts/%s" % host["id"], facts=results["ansible_facts"])
|
||||
|
||||
def _load_stats(self, stats):
|
||||
hosts = sorted(stats.processed.keys())
|
||||
for hostname in hosts:
|
||||
host = self._get_or_create_host(hostname)
|
||||
host_stats = stats.summarize(hostname)
|
||||
|
||||
self._submit_thread(
|
||||
"global",
|
||||
self.client.patch,
|
||||
"/api/v1/hosts/%s" % host["id"],
|
||||
changed=host_stats["changed"],
|
||||
unreachable=host_stats["unreachable"],
|
||||
failed=host_stats["failures"],
|
||||
ok=host_stats["ok"],
|
||||
skipped=host_stats["skipped"],
|
||||
)
|
@ -1,63 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
from ansible.plugins.lookup import LookupBase
|
||||
|
||||
from ara.clients import utils as client_utils
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = """
|
||||
lookup: ara_api
|
||||
author: David Moreau-Simard (@dmsimard)
|
||||
version_added: "2.9"
|
||||
short_description: Queries the ARA API for data
|
||||
description:
|
||||
- Queries the ARA API for data
|
||||
options:
|
||||
_terms:
|
||||
description:
|
||||
- The endpoint to query
|
||||
type: list
|
||||
elements: string
|
||||
required: True
|
||||
"""
|
||||
|
||||
EXAMPLES = """
|
||||
- debug: msg="{{ lookup('ara_api','/api/v1/playbooks/1') }}"
|
||||
"""
|
||||
|
||||
RETURN = """
|
||||
_raw:
|
||||
description: response from query
|
||||
"""
|
||||
|
||||
|
||||
class LookupModule(LookupBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LookupModule, self).__init__(*args, **kwargs)
|
||||
self.client = client_utils.active_client()
|
||||
|
||||
def run(self, terms, variables, **kwargs):
|
||||
ret = []
|
||||
for term in terms:
|
||||
ret.append(self.client.get(term))
|
||||
|
||||
return ret
|
@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from ara.setup.exceptions import (
|
||||
MissingDjangoException,
|
||||
MissingMysqlclientException,
|
||||
MissingPsycopgException,
|
||||
MissingSettingsException,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ara.server.settings")
|
||||
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as e:
|
||||
raise MissingDjangoException from e
|
||||
|
||||
# Validate that the settings file exists and is readable before bootstrapping
|
||||
if not os.path.exists(settings.ARA_SETTINGS):
|
||||
print("[ara] Unable to access or read settings file: %s" % settings.ARA_SETTINGS)
|
||||
raise MissingSettingsException
|
||||
print("[ara] Using settings file: %s" % settings.ARA_SETTINGS)
|
||||
|
||||
if settings.DATABASE_ENGINE == "django.db.backends.postgresql":
|
||||
try:
|
||||
import psycopg2 # noqa
|
||||
except ImportError as e:
|
||||
raise MissingPsycopgException from e
|
||||
|
||||
if settings.DATABASE_ENGINE == "django.db.backends.mysql":
|
||||
try:
|
||||
import MySQLdb # noqa
|
||||
except ImportError as e:
|
||||
raise MissingMysqlclientException from e
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@ -1,23 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
class AraAdminSite(admin.AdminSite):
|
||||
site_header = "Administration"
|
||||
index_title = "Administration Ara"
|
@ -1,22 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.contrib.admin.apps import AdminConfig
|
||||
|
||||
|
||||
class AraAdminConfig(AdminConfig):
|
||||
default_site = "ara.server.admin.AraAdminSite"
|
@ -1,34 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import threading
|
||||
|
||||
from django.db.backends.sqlite3.base import DatabaseWrapper as BaseDatabaseWrapper
|
||||
|
||||
local_storage = threading.local()
|
||||
|
||||
|
||||
class DatabaseWrapper(BaseDatabaseWrapper):
|
||||
"""
|
||||
Custom sqlite database backend meant to work with ara.server.wsgi.distributed_sqlite
|
||||
in order to dynamically load different databases at runtime.
|
||||
"""
|
||||
|
||||
def get_new_connection(self, conn_params):
|
||||
if hasattr(local_storage, "db_path") and local_storage.db_path:
|
||||
conn_params["database"] = local_storage.db_path
|
||||
return super().get_new_connection(conn_params)
|
@ -1,303 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
import warnings
|
||||
|
||||
import tzlocal
|
||||
from django.utils.crypto import get_random_string
|
||||
from dynaconf import LazySettings
|
||||
|
||||
# dynaconf prefers ruamel.yaml but works with pyyaml
|
||||
# https://github.com/rochacbruno/dynaconf/commit/d5cf87cbbdf54625ccf1138a4e4c210956791e61
|
||||
try:
|
||||
from ruamel import yaml as yaml
|
||||
except ImportError:
|
||||
import yaml
|
||||
|
||||
BASE_DIR = os.environ.get("ARA_BASE_DIR", os.path.expanduser("~/.ara/server"))
|
||||
DEFAULT_SETTINGS = os.path.join(BASE_DIR, "settings.yaml")
|
||||
|
||||
settings = LazySettings(
|
||||
environments=True,
|
||||
GLOBAL_ENV_FOR_DYNACONF="ARA",
|
||||
ENVVAR_FOR_DYNACONF="ARA_SETTINGS",
|
||||
SETTINGS_MODULE_FOR_DYNACONF=DEFAULT_SETTINGS,
|
||||
)
|
||||
|
||||
# reread BASE_DIR since it might have gotten changed in the config file.
|
||||
BASE_DIR = settings.get("BASE_DIR", BASE_DIR)
|
||||
|
||||
# Django doesn't set up logging until it's too late to use it in settings.py.
|
||||
# Set it up from the configuration so we can use it.
|
||||
DEBUG = settings.get("DEBUG", False, "@bool")
|
||||
|
||||
LOG_LEVEL = settings.get("LOG_LEVEL", "INFO")
|
||||
# fmt: off
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {"normal": {"format": "%(asctime)s %(levelname)s %(name)s: %(message)s"}},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "normal",
|
||||
"level": LOG_LEVEL,
|
||||
"stream": "ext://sys.stdout",
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
"ara": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL,
|
||||
"propagate": 0
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL
|
||||
},
|
||||
}
|
||||
# fmt: on
|
||||
|
||||
|
||||
# Django built-in server and npm development server
|
||||
ALLOWED_HOSTS = settings.get("ALLOWED_HOSTS", ["::1", "127.0.0.1", "localhost"])
|
||||
CORS_ORIGIN_WHITELIST = settings.get("CORS_ORIGIN_WHITELIST", ["http://127.0.0.1:8000", "http://localhost:3000"])
|
||||
CORS_ORIGIN_REGEX_WHITELIST = settings.get("CORS_ORIGIN_REGEX_WHITELIST", [])
|
||||
CORS_ORIGIN_ALLOW_ALL = settings.get("CORS_ORIGIN_ALLOW_ALL", False)
|
||||
|
||||
ADMINS = settings.get("ADMINS", ())
|
||||
|
||||
READ_LOGIN_REQUIRED = settings.get("READ_LOGIN_REQUIRED", False, "@bool")
|
||||
WRITE_LOGIN_REQUIRED = settings.get("WRITE_LOGIN_REQUIRED", False, "@bool")
|
||||
EXTERNAL_AUTH = settings.get("EXTERNAL_AUTH", False, "@bool")
|
||||
|
||||
|
||||
def get_secret_key():
|
||||
if not settings.get("SECRET_KEY"):
|
||||
print("[ara] No setting found for SECRET_KEY. Generating a random key...")
|
||||
return get_random_string(length=50)
|
||||
return settings.get("SECRET_KEY")
|
||||
|
||||
|
||||
SECRET_KEY = get_secret_key()
|
||||
|
||||
# Whether or not to enable the distributed sqlite database backend and WSGI application.
|
||||
DISTRIBUTED_SQLITE = settings.get("DISTRIBUTED_SQLITE", False)
|
||||
|
||||
# Under which URL should requests be delegated to the distributed sqlite wsgi application
|
||||
DISTRIBUTED_SQLITE_PREFIX = settings.get("DISTRIBUTED_SQLITE_PREFIX", "ara-report")
|
||||
|
||||
# Root directory under which databases will be found relative to the requested URLs.
|
||||
# This will restrict where the WSGI application will go to seek out databases.
|
||||
# For example, the URL "example.org/some/path/ara-report" would translate to
|
||||
# "/var/www/logs/some/path/ara-report" instead of "/some/path/ara-report".
|
||||
DISTRIBUTED_SQLITE_ROOT = settings.get("DISTRIBUTED_SQLITE_ROOT", "/var/www/logs")
|
||||
|
||||
if DISTRIBUTED_SQLITE:
|
||||
WSGI_APPLICATION = "ara.server.wsgi.distributed_sqlite"
|
||||
DATABASE_ENGINE = settings.get("DATABASE_ENGINE", "ara.server.db.backends.distributed_sqlite")
|
||||
else:
|
||||
WSGI_APPLICATION = "ara.server.wsgi.application"
|
||||
DATABASE_ENGINE = settings.get("DATABASE_ENGINE", "django.db.backends.sqlite3")
|
||||
|
||||
# We're not expecting ARA to use multiple concurrent databases.
|
||||
# Make it easier for users to specify the configuration for a single database.
|
||||
DATABASE_ENGINE = settings.get("DATABASE_ENGINE", "django.db.backends.sqlite3")
|
||||
DATABASE_NAME = settings.get("DATABASE_NAME", os.path.join(BASE_DIR, "ansible.sqlite"))
|
||||
DATABASE_USER = settings.get("DATABASE_USER", None)
|
||||
DATABASE_PASSWORD = settings.get("DATABASE_PASSWORD", None)
|
||||
DATABASE_HOST = settings.get("DATABASE_HOST", None)
|
||||
DATABASE_PORT = settings.get("DATABASE_PORT", None)
|
||||
DATABASE_CONN_MAX_AGE = settings.get("DATABASE_CONN_MAX_AGE", 0)
|
||||
DATABASE_OPTIONS = settings.get("DATABASE_OPTIONS", {})
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": DATABASE_ENGINE,
|
||||
"NAME": DATABASE_NAME,
|
||||
"USER": DATABASE_USER,
|
||||
"PASSWORD": DATABASE_PASSWORD,
|
||||
"HOST": DATABASE_HOST,
|
||||
"PORT": DATABASE_PORT,
|
||||
"CONN_MAX_AGE": DATABASE_CONN_MAX_AGE,
|
||||
"OPTIONS": DATABASE_OPTIONS,
|
||||
}
|
||||
}
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"health_check",
|
||||
"health_check.db",
|
||||
"corsheaders",
|
||||
"rest_framework",
|
||||
"django_filters",
|
||||
"ara.api",
|
||||
"ara.ui",
|
||||
"ara.server.apps.AraAdminConfig",
|
||||
]
|
||||
|
||||
EXTERNAL_AUTH_MIDDLEWARE = []
|
||||
if EXTERNAL_AUTH:
|
||||
EXTERNAL_AUTH_MIDDLEWARE = ["django.contrib.auth.middleware.RemoteUserMiddleware"]
|
||||
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.RemoteUserBackend"]
|
||||
REST_FRAMEWORK_AUTH = ("rest_framework.authentication.RemoteUserAuthentication",)
|
||||
else:
|
||||
REST_FRAMEWORK_AUTH = ("rest_framework.authentication.BasicAuthentication",)
|
||||
|
||||
# fmt: off
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
] + EXTERNAL_AUTH_MIDDLEWARE + [
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware"
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
USE_TZ = True
|
||||
LOCAL_TIME_ZONE = tzlocal.get_localzone().zone
|
||||
TIME_ZONE = settings.get("TIME_ZONE", LOCAL_TIME_ZONE)
|
||||
|
||||
# We do not currently support internationalization and localization, turn these
|
||||
# off.
|
||||
USE_I18N = False
|
||||
USE_L10N = False
|
||||
|
||||
# whitenoise serves static files without needing to use "collectstatic"
|
||||
WHITENOISE_USE_FINDERS = True
|
||||
# https://github.com/evansd/whitenoise/issues/215
|
||||
# Whitenoise raises a warning if STATIC_ROOT doesn't exist
|
||||
warnings.filterwarnings("ignore", message="No directory at", module="whitenoise.base")
|
||||
|
||||
STATIC_URL = settings.get("STATIC_URL", "/static/")
|
||||
STATIC_ROOT = settings.get("STATIC_ROOT", os.path.join(BASE_DIR, "www", "static"))
|
||||
|
||||
MEDIA_URL = settings.get("MEDIA_URL", "/media/")
|
||||
MEDIA_ROOT = settings.get("MEDIA_ROOT", os.path.join(BASE_DIR, "www", "media"))
|
||||
|
||||
ROOT_URLCONF = "ara.server.urls"
|
||||
APPEND_SLASH = False
|
||||
|
||||
PAGE_SIZE = settings.get("PAGE_SIZE", 100)
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
|
||||
"PAGE_SIZE": PAGE_SIZE,
|
||||
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
"rest_framework.renderers.BrowsableAPIRenderer",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": (
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"rest_framework.parsers.FormParser",
|
||||
"rest_framework.parsers.MultiPartParser",
|
||||
),
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": REST_FRAMEWORK_AUTH,
|
||||
"DEFAULT_PERMISSION_CLASSES": ("ara.api.auth.APIAccessPermission",),
|
||||
"TEST_REQUEST_DEFAULT_FORMAT": "json",
|
||||
"UNICODE_JSON": False,
|
||||
}
|
||||
|
||||
ARA_SETTINGS = os.getenv("ARA_SETTINGS", DEFAULT_SETTINGS)
|
||||
|
||||
# TODO: Split this out to a CLI command (django-admin command ?)
|
||||
|
||||
# Ensure default base configuration/data directory exists
|
||||
if not os.path.isdir(BASE_DIR):
|
||||
print("[ara] Creating data & configuration directory: %s" % BASE_DIR)
|
||||
os.makedirs(BASE_DIR, mode=0o700)
|
||||
|
||||
if not os.path.exists(DEFAULT_SETTINGS) and "ARA_SETTINGS" not in os.environ:
|
||||
SETTINGS = dict(
|
||||
BASE_DIR=BASE_DIR,
|
||||
ALLOWED_HOSTS=ALLOWED_HOSTS.to_list(),
|
||||
CORS_ORIGIN_WHITELIST=CORS_ORIGIN_WHITELIST.to_list(),
|
||||
CORS_ORIGIN_REGEX_WHITELIST=CORS_ORIGIN_REGEX_WHITELIST.to_list(),
|
||||
CORS_ORIGIN_ALLOW_ALL=CORS_ORIGIN_ALLOW_ALL,
|
||||
EXTERNAL_AUTH=EXTERNAL_AUTH,
|
||||
SECRET_KEY=SECRET_KEY,
|
||||
DATABASE_ENGINE=DATABASE_ENGINE,
|
||||
DATABASE_NAME=DATABASE_NAME,
|
||||
DATABASE_USER=DATABASE_USER,
|
||||
DATABASE_PASSWORD=DATABASE_PASSWORD,
|
||||
DATABASE_HOST=DATABASE_HOST,
|
||||
DATABASE_PORT=DATABASE_PORT,
|
||||
DATABASE_CONN_MAX_AGE=DATABASE_CONN_MAX_AGE,
|
||||
DATABASE_OPTIONS=DATABASE_OPTIONS.to_dict(),
|
||||
DEBUG=DEBUG,
|
||||
LOG_LEVEL=LOG_LEVEL,
|
||||
LOGGING=LOGGING,
|
||||
READ_LOGIN_REQUIRED=READ_LOGIN_REQUIRED,
|
||||
WRITE_LOGIN_REQUIRED=WRITE_LOGIN_REQUIRED,
|
||||
PAGE_SIZE=PAGE_SIZE,
|
||||
DISTRIBUTED_SQLITE=DISTRIBUTED_SQLITE,
|
||||
DISTRIBUTED_SQLITE_PREFIX=DISTRIBUTED_SQLITE_PREFIX,
|
||||
DISTRIBUTED_SQLITE_ROOT=DISTRIBUTED_SQLITE_ROOT,
|
||||
TIME_ZONE=TIME_ZONE,
|
||||
)
|
||||
with open(DEFAULT_SETTINGS, "w+") as settings_file:
|
||||
comment = textwrap.dedent(
|
||||
"""
|
||||
---
|
||||
# This is a default settings template generated by ARA.
|
||||
# To use a settings file such as this one, you need to export the
|
||||
# ARA_SETTINGS environment variable like so:
|
||||
# $ export ARA_SETTINGS="{}"
|
||||
|
||||
""".format(
|
||||
DEFAULT_SETTINGS
|
||||
)
|
||||
)
|
||||
print("[ara] Writing default settings to %s" % DEFAULT_SETTINGS)
|
||||
settings_file.write(comment.lstrip())
|
||||
yaml.dump({"default": SETTINGS}, settings_file, default_flow_style=False)
|
@ -1,48 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import urllib.parse
|
||||
|
||||
import pbr.version
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
# fmt: off
|
||||
class APIIndex(APIView):
|
||||
def get(self, request):
|
||||
return Response({
|
||||
"kind": "ara",
|
||||
"version": pbr.version.VersionInfo("ara").release_string(),
|
||||
"api": list(map(lambda x: urllib.parse.urljoin(
|
||||
request.build_absolute_uri(), x),
|
||||
[
|
||||
"v1/",
|
||||
]))
|
||||
})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include("ara.ui.urls")),
|
||||
path("api/", APIIndex.as_view(), name='api-index'),
|
||||
path("api/v1/", include("ara.api.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path("healthcheck/", include("health_check.urls")),
|
||||
]
|
||||
# fmt: on
|
@ -1,105 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ara.setup.exceptions import MissingDjangoException
|
||||
|
||||
try:
|
||||
from django.core.handlers.wsgi import get_path_info, get_script_name
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
except ImportError as e:
|
||||
raise MissingDjangoException from e
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ara.server.settings")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The default WSGI application
|
||||
application = get_wsgi_application()
|
||||
|
||||
|
||||
def handle_405(start_response):
|
||||
start_response("405 Method Not Allowed", [("content-type", "text/html")])
|
||||
return [b"<h1>Method Not Allowed</h1><p>This endpoint is read only.</p>"]
|
||||
|
||||
|
||||
def handle_404(start_response):
|
||||
start_response("404 Not Found", [("content-type", "text/html")])
|
||||
return [b"<h1>Not Found</h1><p>The requested resource was not found on this server.</p>"]
|
||||
|
||||
|
||||
def distributed_sqlite(environ, start_response):
|
||||
"""
|
||||
Custom WSGI application meant to work with ara.server.db.backends.distributed_sqlite
|
||||
in order to dynamically load different databases at runtime.
|
||||
"""
|
||||
# This endpoint is read only, do not accept write requests.
|
||||
if environ["REQUEST_METHOD"] not in ["GET", "HEAD", "OPTIONS"]:
|
||||
handle_405(start_response)
|
||||
|
||||
script_name = get_script_name(environ)
|
||||
path_info = get_path_info(environ)
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
# The root under which database files are expected
|
||||
root = settings.DISTRIBUTED_SQLITE_ROOT
|
||||
# The prefix after which everything should be delegated (ex: /ara-report)
|
||||
prefix = settings.DISTRIBUTED_SQLITE_PREFIX
|
||||
|
||||
# Static assets should always be served by the regular app
|
||||
if path_info.startswith(settings.STATIC_URL):
|
||||
return application(environ, start_response)
|
||||
|
||||
if prefix not in path_info:
|
||||
logger.warn("Ignoring request: URL does not contain delegated prefix (%s)" % prefix)
|
||||
return handle_404(start_response)
|
||||
|
||||
# Slice path_info up until after the prefix to obtain the requested directory
|
||||
i = path_info.find(prefix) + len(prefix)
|
||||
fs_path = path_info[:i]
|
||||
|
||||
# Make sure we aren't escaping outside the root and the directory exists
|
||||
db_dir = os.path.abspath(os.path.join(root, fs_path.lstrip("/")))
|
||||
if not db_dir.startswith(root):
|
||||
logger.warn("Ignoring request: path is outside the root (%s)" % db_dir)
|
||||
return handle_404(start_response)
|
||||
elif not os.path.exists(db_dir):
|
||||
logger.warn("Ignoring request: database directory not found (%s)" % db_dir)
|
||||
return handle_404(start_response)
|
||||
|
||||
# Find the database file and make sure it exists
|
||||
db_file = os.path.join(db_dir, "ansible.sqlite")
|
||||
if not os.path.exists(db_file):
|
||||
logger.warn("Ignoring request: database file not found (%s)" % db_file)
|
||||
return handle_404(start_response)
|
||||
|
||||
# Tell Django about the new URLs it should be using
|
||||
environ["SCRIPT_NAME"] = script_name + fs_path
|
||||
environ["PATH_INFO"] = path_info[len(fs_path) :] # noqa: E203
|
||||
|
||||
# Store the path of the database in a thread so the distributed_sqlite
|
||||
# database backend can retrieve it.
|
||||
from ara.server.db.backends.distributed_sqlite.base import local_storage
|
||||
|
||||
local_storage.db_path = db_file
|
||||
try:
|
||||
return application(environ, start_response)
|
||||
finally:
|
||||
del local_storage.db_path
|
@ -1,5 +0,0 @@
|
||||
This directory contains scripts meant to help configuring ARA with Ansible.
|
||||
|
||||
For more information, visit the documentation_.
|
||||
|
||||
.. _documentation: https://ara.readthedocs.io/en/latest/ansible-configuration.html
|
@ -1,26 +0,0 @@
|
||||
# Copyright (c) 2018 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
|
||||
# The path where ARA is installed (parent directory)
|
||||
path = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
plugins = os.path.abspath(os.path.join(path, "plugins"))
|
||||
action_plugins = os.path.abspath(os.path.join(plugins, "action"))
|
||||
callback_plugins = os.path.abspath(os.path.join(plugins, "callback"))
|
||||
lookup_plugins = os.path.abspath(os.path.join(plugins, "lookup"))
|
@ -1,23 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
from . import action_plugins
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(action_plugins)
|
@ -1,32 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
from . import action_plugins, callback_plugins, lookup_plugins
|
||||
|
||||
config = """
|
||||
[defaults]
|
||||
callback_plugins={}
|
||||
action_plugins={}
|
||||
lookup_plugins={}
|
||||
""".format(
|
||||
callback_plugins, action_plugins, lookup_plugins
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(config.strip())
|
@ -1,23 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
from . import callback_plugins
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(callback_plugins)
|
@ -1,49 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
from distutils.sysconfig import get_python_lib
|
||||
|
||||
from . import action_plugins, callback_plugins, lookup_plugins
|
||||
|
||||
exports = """
|
||||
export ANSIBLE_CALLBACK_PLUGINS=${{ANSIBLE_CALLBACK_PLUGINS:-}}${{ANSIBLE_CALLBACK_PLUGINS+:}}{}
|
||||
export ANSIBLE_ACTION_PLUGINS=${{ANSIBLE_ACTION_PLUGINS:-}}${{ANSIBLE_ACTION_PLUGINS+:}}{}
|
||||
export ANSIBLE_LOOKUP_PLUGINS=${{ANSIBLE_LOOKUP_PLUGINS:-}}${{ANSIBLE_LOOKUP_PLUGINS+:}}{}
|
||||
""".format(
|
||||
callback_plugins, action_plugins, lookup_plugins
|
||||
)
|
||||
|
||||
if "VIRTUAL_ENV" in os.environ:
|
||||
""" PYTHONPATH may be exported when 'ara' module is installed in a
|
||||
virtualenv and ansible is installed on system python to avoid ansible
|
||||
failure to find ara module.
|
||||
"""
|
||||
# inspired by https://stackoverflow.com/a/122340/99834
|
||||
lib = get_python_lib()
|
||||
if "PYTHONPATH" in os.environ:
|
||||
python_paths = os.environ["PYTHONPATH"].split(os.pathsep)
|
||||
else:
|
||||
python_paths = []
|
||||
if lib not in python_paths:
|
||||
python_paths.append(lib)
|
||||
exports += "export PYTHONPATH=${PYTHONPATH:-}${PYTHONPATH+:}%s\n" % os.pathsep.join(python_paths)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(exports.strip())
|
@ -1,40 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
class MissingDjangoException(Exception):
|
||||
def __init__(self):
|
||||
exc = "The server dependencies must be installed to record data offline or run the API server."
|
||||
super().__init__(exc)
|
||||
|
||||
|
||||
class MissingPsycopgException(Exception):
|
||||
def __init__(self):
|
||||
exc = "The psycopg2 python library must be installed in order to use the PostgreSQL database engine."
|
||||
super().__init__(exc)
|
||||
|
||||
|
||||
class MissingMysqlclientException(Exception):
|
||||
def __init__(self):
|
||||
exc = "The mysqlclient python library must be installed in order to use the MySQL database engine."
|
||||
super().__init__(exc)
|
||||
|
||||
|
||||
class MissingSettingsException(Exception):
|
||||
def __init__(self):
|
||||
exc = "The specified settings file does not exist or permissions are insufficient to read it."
|
||||
super().__init__(exc)
|
@ -1,23 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
from . import lookup_plugins
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(lookup_plugins)
|
@ -1,23 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
from . import path
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(path)
|
@ -1,23 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
from . import plugins
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(plugins)
|
@ -1,22 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UiConfig(AppConfig):
|
||||
name = "ara.ui"
|
@ -1,43 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django import forms
|
||||
|
||||
from ara.api import models
|
||||
|
||||
|
||||
class PlaybookSearchForm(forms.Form):
|
||||
ansible_version = forms.CharField(label="Ansible version", max_length=255, required=False)
|
||||
controller = forms.CharField(label="Playbook controller", max_length=255, required=False)
|
||||
name = forms.CharField(label="Playbook name", max_length=255, required=False)
|
||||
path = forms.CharField(label="Playbook path", max_length=255, required=False)
|
||||
status = forms.MultipleChoiceField(
|
||||
widget=forms.CheckboxSelectMultiple, choices=models.Playbook.STATUS, required=False
|
||||
)
|
||||
label = forms.CharField(label="Playbook label", max_length=255, required=False)
|
||||
started_after = forms.DateField(label="Started after", required=False)
|
||||
order = forms.CharField(label="Order", max_length=255, required=False)
|
||||
|
||||
|
||||
class ResultSearchForm(forms.Form):
|
||||
host = forms.CharField(label="Host id", max_length=10, required=False)
|
||||
task = forms.CharField(label="Task id", max_length=10, required=False)
|
||||
changed = forms.BooleanField(label="Changed", required=False)
|
||||
|
||||
status = forms.MultipleChoiceField(
|
||||
widget=forms.CheckboxSelectMultiple, choices=models.Result.STATUS, required=False
|
||||
)
|
@ -1,132 +0,0 @@
|
||||
import codecs
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from ara.api import models, serializers
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generates a static tree of the web application"
|
||||
rendered = 0
|
||||
|
||||
@staticmethod
|
||||
def create_dirs(path):
|
||||
# create main output dir
|
||||
if not os.path.exists(path):
|
||||
os.mkdir(path)
|
||||
|
||||
# create subdirs
|
||||
dirs = ["playbooks", "files", "hosts", "results", "records"]
|
||||
for dir in dirs:
|
||||
if not os.path.exists(os.path.join(path, dir)):
|
||||
os.mkdir(os.path.join(path, dir))
|
||||
|
||||
# Retrieve static assets (../../static)
|
||||
shutil.rmtree(os.path.join(path, "static"), ignore_errors=True)
|
||||
ui_path = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
shutil.copytree(os.path.join(ui_path, "static"), os.path.join(path, "static"))
|
||||
# copy robots.txt from templates to root directory
|
||||
shutil.copyfile(os.path.join(ui_path, "templates/robots.txt"), os.path.join(path, "robots.txt"))
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("path", help="Path where the static files will be built in", type=str)
|
||||
|
||||
def render(self, template, destination, **kwargs):
|
||||
self.rendered += 1
|
||||
with open(destination, "w") as f:
|
||||
f.write(render_to_string(template, kwargs))
|
||||
|
||||
def handle(self, *args, **options):
|
||||
path = options.get("path")
|
||||
self.create_dirs(path)
|
||||
|
||||
# TODO: Leverage ui views directly instead of duplicating logic here
|
||||
query = models.Playbook.objects.all().order_by("-id")
|
||||
serializer = serializers.ListPlaybookSerializer(query, many=True)
|
||||
|
||||
print("[ara] Generating static files for %s playbooks at %s..." % (query.count(), path))
|
||||
|
||||
# Index
|
||||
destination = os.path.join(path, "index.html")
|
||||
data = {"data": {"results": serializer.data}, "static_generation": True, "page": "index"}
|
||||
self.render("index.html", destination, **data)
|
||||
|
||||
# Escape surrogates to prevent UnicodeEncodeError exceptions
|
||||
codecs.register_error("strict", codecs.lookup_error("surrogateescape"))
|
||||
|
||||
# Playbooks
|
||||
for pb in query:
|
||||
playbook = serializers.DetailedPlaybookSerializer(pb)
|
||||
hosts = serializers.ListHostSerializer(
|
||||
models.Host.objects.filter(playbook=playbook.data["id"]).order_by("name").all(), many=True
|
||||
)
|
||||
files = serializers.ListFileSerializer(
|
||||
models.File.objects.filter(playbook=playbook.data["id"]).all(), many=True
|
||||
)
|
||||
records = serializers.ListRecordSerializer(
|
||||
models.Record.objects.filter(playbook=playbook.data["id"]).all(), many=True
|
||||
)
|
||||
results = serializers.ListResultSerializer(
|
||||
models.Result.objects.filter(playbook=playbook.data["id"]).all(), many=True
|
||||
)
|
||||
|
||||
# Backfill task and host data into results
|
||||
for result in results.data:
|
||||
task_id = result["task"]
|
||||
result["task"] = serializers.SimpleTaskSerializer(models.Task.objects.get(pk=task_id)).data
|
||||
host_id = result["host"]
|
||||
result["host"] = serializers.SimpleHostSerializer(models.Host.objects.get(pk=host_id)).data
|
||||
|
||||
# Results are paginated in the dynamic version and the template expects data in a specific format
|
||||
formatted_results = {"count": len(results.data), "results": results.data}
|
||||
|
||||
destination = os.path.join(path, "playbooks/%s.html" % playbook.data["id"])
|
||||
self.render(
|
||||
"playbook.html",
|
||||
destination,
|
||||
static_generation=True,
|
||||
playbook=playbook.data,
|
||||
hosts=hosts.data,
|
||||
files=files.data,
|
||||
records=records.data,
|
||||
results=formatted_results,
|
||||
current_page_results=None,
|
||||
search_form=None,
|
||||
)
|
||||
|
||||
# Files
|
||||
query = models.File.objects.all()
|
||||
for file in query.all():
|
||||
destination = os.path.join(path, "files/%s.html" % file.id)
|
||||
serializer = serializers.DetailedFileSerializer(file)
|
||||
data = {"file": serializer.data, "static_generation": True}
|
||||
self.render("file.html", destination, **data)
|
||||
|
||||
# Hosts
|
||||
query = models.Host.objects.all()
|
||||
for host in query.all():
|
||||
destination = os.path.join(path, "hosts/%s.html" % host.id)
|
||||
serializer = serializers.DetailedHostSerializer(host)
|
||||
data = {"host": serializer.data, "static_generation": True}
|
||||
self.render("host.html", destination, **data)
|
||||
|
||||
# Results
|
||||
query = models.Result.objects.all()
|
||||
for result in query.all():
|
||||
destination = os.path.join(path, "results/%s.html" % result.id)
|
||||
serializer = serializers.DetailedResultSerializer(result)
|
||||
data = {"result": serializer.data, "static_generation": True}
|
||||
self.render("result.html", destination, **data)
|
||||
|
||||
# Records
|
||||
query = models.Record.objects.all()
|
||||
for record in query.all():
|
||||
destination = os.path.join(path, "records/%s.html" % record.id)
|
||||
serializer = serializers.DetailedRecordSerializer(record)
|
||||
data = {"record": serializer.data, "static_generation": True}
|
||||
self.render("record.html", destination, **data)
|
||||
|
||||
print("[ara] %s files generated." % self.rendered)
|
@ -1,83 +0,0 @@
|
||||
# Copyright (c) 2019 Red Hat, Inc.
|
||||
#
|
||||
# This file is part of ARA Records Ansible.
|
||||
#
|
||||
# ARA 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.
|
||||
#
|
||||
# ARA 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 ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.utils.urls import remove_query_param, replace_query_param
|
||||
|
||||
|
||||
class LimitOffsetPaginationWithLinks(LimitOffsetPagination):
|
||||
"""
|
||||
Extends LimitOffsetPagination to provide links
|
||||
to first and last pages as well as the limit and offset, if available.
|
||||
Generates relative links instead of absolute URIs.
|
||||
"""
|
||||
|
||||
def get_next_link(self):
|
||||
if self.offset + self.limit >= self.count:
|
||||
return None
|
||||
|
||||
url = self.request.get_full_path()
|
||||
url = replace_query_param(url, self.limit_query_param, self.limit)
|
||||
|
||||
offset = self.offset + self.limit
|
||||
return replace_query_param(url, self.offset_query_param, offset)
|
||||
|
||||
def get_previous_link(self):
|
||||
if self.offset <= 0:
|
||||
return None
|
||||
|
||||
url = self.request.get_full_path()
|
||||
url = replace_query_param(url, self.limit_query_param, self.limit)
|
||||
|
||||
if self.offset - self.limit <= 0:
|
||||
return remove_query_param(url, self.offset_query_param)
|
||||
|
||||
offset = self.offset - self.limit
|
||||
return replace_query_param(url, self.offset_query_param, offset)
|
||||
|
||||
def get_first_link(self):
|
||||
if self.offset <= 0:
|
||||
return None
|
||||
url = self.request.get_full_path()
|
||||
return remove_query_param(url, self.offset_query_param)
|
||||
|
||||
def get_last_link(self):
|
||||
if self.offset + self.limit >= self.count:
|
||||
return None
|
||||
url = self.request.get_full_path()
|
||||
url = replace_query_param(url, self.limit_query_param, self.limit)
|
||||
offset = self.count - self.limit
|
||||
return replace_query_param(url, self.offset_query_param, offset)
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
return Response(
|
||||
OrderedDict(
|
||||
[
|
||||
("count", self.count),
|
||||
("next", self.get_next_link()),
|
||||
("previous", self.get_previous_link()),
|
||||
("first", self.get_first_link()),
|
||||
("last", self.get_last_link()),
|
||||
("limit", self.limit),
|
||||
("offset", self.offset),
|
||||
("results", data),
|
||||
]
|
||||
)
|
||||
)
|
@ -1,9 +0,0 @@
|
||||
Vendored patternfly assets
|
||||
==========================
|
||||
|
||||
- https://unpkg.com/@patternfly/patternfly@2.56.3/patternfly.min.css
|
||||
- https://unpkg.com/@patternfly/patternfly@2.56.3/assets/fonts/overpass-webfont/overpass-semibold.woff2
|
||||
- https://unpkg.com/@patternfly/patternfly@2.56.3/assets/fonts/overpass-webfont/overpass-light.woff2
|
||||
- https://unpkg.com/@patternfly/patternfly@2.56.3/assets/fonts/overpass-webfont/overpass-regular.woff2
|
||||
- https://unpkg.com/@patternfly/patternfly@2.56.3/assets/fonts/overpass-webfont/overpass-bold.woff2
|
||||
- https://unpkg.com/@patternfly/patternfly@2.56.3/assets/fonts/webfonts/fa-solid-900.woff2
|
@ -1,43 +0,0 @@
|
||||
details {
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
padding: .5em .5em 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
font-weight: bold;
|
||||
margin: -.5em -.5em 0;
|
||||
padding: .5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
details[open] {
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
details[open] summary {
|
||||
border-bottom: 1px solid #aaa;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color:whitesmoke;
|
||||
opacity: 0.9;
|
||||
transition: 0.1s;
|
||||
}
|
||||
|
||||
.pf-c-form-control {
|
||||
width: 175px;
|
||||
}
|
||||
|
||||
.pf-c-alert p {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
#host-details pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#result-details pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
ara/ui/static/css/patternfly.min.css
vendored
2
ara/ui/static/css/patternfly.min.css
vendored
File diff suppressed because one or more lines are too long
@ -1,63 +0,0 @@
|
||||
/* https://github.com/richleland/pygments-css */
|
||||
.codehilite { padding-left: 10px; }
|
||||
.codehilite .hll { background-color: #ffffcc }
|
||||
.codehilite .c { color: #999988; font-style: italic } /* Comment */
|
||||
.codehilite .err { color: #a61717; background-color: #e3d2d2 } /* Error */
|
||||
.codehilite .k { color: #000000; font-weight: bold } /* Keyword */
|
||||
.codehilite .o { color: #000000; font-weight: bold } /* Operator */
|
||||
.codehilite .cm { color: #999988; font-style: italic } /* Comment.Multiline */
|
||||
.codehilite .cp { color: #999999; font-weight: bold; font-style: italic } /* Comment.Preproc */
|
||||
.codehilite .c1 { color: #999988; font-style: italic } /* Comment.Single */
|
||||
.codehilite .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */
|
||||
.codehilite .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */
|
||||
.codehilite .ge { color: #000000; font-style: italic } /* Generic.Emph */
|
||||
.codehilite .gr { color: #aa0000 } /* Generic.Error */
|
||||
.codehilite .gh { color: #999999 } /* Generic.Heading */
|
||||
.codehilite .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */
|
||||
.codehilite .go { color: #888888 } /* Generic.Output */
|
||||
.codehilite .gp { color: #555555 } /* Generic.Prompt */
|
||||
.codehilite .gs { font-weight: bold } /* Generic.Strong */
|
||||
.codehilite .gu { color: #aaaaaa } /* Generic.Subheading */
|
||||
.codehilite .gt { color: #aa0000 } /* Generic.Traceback */
|
||||
.codehilite .kc { color: #000000; font-weight: bold } /* Keyword.Constant */
|
||||
.codehilite .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */
|
||||
.codehilite .kn { color: #000000; font-weight: bold } /* Keyword.Namespace */
|
||||
.codehilite .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */
|
||||
.codehilite .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */
|
||||
.codehilite .kt { color: #445588; font-weight: bold } /* Keyword.Type */
|
||||
.codehilite .m { color: #009999 } /* Literal.Number */
|
||||
.codehilite .s { color: #d01040 } /* Literal.String */
|
||||
.codehilite .na { color: #008080 } /* Name.Attribute */
|
||||
.codehilite .nb { color: #0086B3 } /* Name.Builtin */
|
||||
.codehilite .nc { color: #445588; font-weight: bold } /* Name.Class */
|
||||
.codehilite .no { color: #008080 } /* Name.Constant */
|
||||
.codehilite .nd { color: #3c5d5d; font-weight: bold } /* Name.Decorator */
|
||||
.codehilite .ni { color: #800080 } /* Name.Entity */
|
||||
.codehilite .ne { color: #990000; font-weight: bold } /* Name.Exception */
|
||||
.codehilite .nf { color: #990000; font-weight: bold } /* Name.Function */
|
||||
.codehilite .nl { color: #990000; font-weight: bold } /* Name.Label */
|
||||
.codehilite .nn { color: #555555 } /* Name.Namespace */
|
||||
.codehilite .nt { color: #000080 } /* Name.Tag */
|
||||
.codehilite .nv { color: #008080 } /* Name.Variable */
|
||||
.codehilite .ow { color: #000000; font-weight: bold } /* Operator.Word */
|
||||
.codehilite .w { color: #bbbbbb } /* Text.Whitespace */
|
||||
.codehilite .mf { color: #009999 } /* Literal.Number.Float */
|
||||
.codehilite .mh { color: #009999 } /* Literal.Number.Hex */
|
||||
.codehilite .mi { color: #009999 } /* Literal.Number.Integer */
|
||||
.codehilite .mo { color: #009999 } /* Literal.Number.Oct */
|
||||
.codehilite .sb { color: #d01040 } /* Literal.String.Backtick */
|
||||
.codehilite .sc { color: #d01040 } /* Literal.String.Char */
|
||||
.codehilite .sd { color: #d01040 } /* Literal.String.Doc */
|
||||
.codehilite .s2 { color: #d01040 } /* Literal.String.Double */
|
||||
.codehilite .se { color: #d01040 } /* Literal.String.Escape */
|
||||
.codehilite .sh { color: #d01040 } /* Literal.String.Heredoc */
|
||||
.codehilite .si { color: #d01040 } /* Literal.String.Interpol */
|
||||
.codehilite .sx { color: #d01040 } /* Literal.String.Other */
|
||||
.codehilite .sr { color: #009926 } /* Literal.String.Regex */
|
||||
.codehilite .s1 { color: #d01040 } /* Literal.String.Single */
|
||||
.codehilite .ss { color: #990073 } /* Literal.String.Symbol */
|
||||
.codehilite .bp { color: #999999 } /* Name.Builtin.Pseudo */
|
||||
.codehilite .vc { color: #008080 } /* Name.Variable.Class */
|
||||
.codehilite .vg { color: #008080 } /* Name.Variable.Global */
|
||||
.codehilite .vi { color: #008080 } /* Name.Variable.Instance */
|
||||
.codehilite .il { color: #009999 } /* Literal.Number.Integer.Long */
|
Binary file not shown.
Before Width: 32px | Height: 32px | Size: 5.3 KiB |
File diff suppressed because one or more lines are too long
Before (image error) Size: 6.0 KiB |
@ -1,62 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="layout-pf">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{% if page != "index" %}../{% endif %}static/css/patternfly.min.css">
|
||||
<link rel="stylesheet" href="{% if page != "index" %}../{% endif %}static/css/ara.css">
|
||||
{% if file.id or record.id or host.id or result.id %}
|
||||
<link rel="stylesheet" href="{% if page != "index" %}../{% endif %}static/css/pygments.css">
|
||||
{% endif %}
|
||||
<link rel="shortcut icon" href="{% if page != "index" %}../{% endif %}static/images/favicon.ico">
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="pf-c-page">
|
||||
<header role="banner" class="pf-c-page__header">
|
||||
<div class="pf-c-page__header-brand">
|
||||
{% if static_generation %}
|
||||
<a class="pf-c-page__header-brand-link" href="{% if page != "index" %}../{% endif %}">
|
||||
{% else %}
|
||||
<a class="pf-c-page__header-brand-link" href="{% url 'ui:index' %}">
|
||||
{% endif %}
|
||||
<img class="pf-c-brand" src="{% if page != "index" %}../{% endif %}static/images/logo.svg" alt="ARA Records Ansible">
|
||||
</a>
|
||||
</div>
|
||||
<div class="pf-c-page__header-nav">
|
||||
<nav class="pf-c-nav" aria-label="Global">
|
||||
<button class="pf-c-nav__scroll-button" aria-label="Scroll left">
|
||||
<i class="fas fa-angle-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="pf-c-nav__horizontal-list">
|
||||
<li class="pf-c-nav__item">
|
||||
{% if page == "index" %}
|
||||
<a href="{% if not static_generation %}{% url 'ui:index' %}{% endif %}" class="pf-c-nav__link pf-m-current" aria-current="page">Playbooks</a>
|
||||
{% else %}
|
||||
<a href="{% if not static_generation %}{% url 'ui:index' %}{% else %}../{% endif %}" class="pf-c-nav__link">Playbooks</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% include "partials/api_link.html" %}
|
||||
<li class="pf-c-nav__item">
|
||||
<a href="https://ara.readthedocs.io" class="pf-c-nav__link" target="_blank">Docs</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main role="main" class="pf-c-page__main">
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
{% block body %}
|
||||
{% endblock %}
|
||||
</section>
|
||||
<section class="pf-c-page__main-section">
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -1,14 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
{% include "partials/playbook_card.html" with playbook=file.playbook %}
|
||||
{% load pygments_highlights %}
|
||||
|
||||
<div class="pf-c-card" style="margin: 1em 0;">
|
||||
<div class="pf-c-card__header pf-c-title pf-m-md">
|
||||
<strong>{{ file.path }}</strong>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
{{ file.content | format_yaml | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,33 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
{% include "partials/playbook_card.html" with playbook=host.playbook %}
|
||||
{% load pygments_highlights %}
|
||||
|
||||
<div class="pf-c-card" style="margin: 1em 0;">
|
||||
<div class="pf-c-card__header pf-c-title pf-m-md">
|
||||
Host: {{ host.name }}
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<table class="pf-c-table pf-m-grid-md pf-m-compact" role="grid" id="host-details">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col" class="pf-m-width-20">Fact</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-width-80">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for fact, value in host.facts.items %}
|
||||
<tr role="row">
|
||||
<td role="cell" id="{{ fact }}" data-label="{{ fact }}" class="pf-m-width-20">
|
||||
<a href="#{{ fact }}">{{ fact }}</a>
|
||||
</td>
|
||||
<td role="cell" data-label="Value" class="pf-m-width-80">
|
||||
{{ value | format_data | safe }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,238 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load datetime_formatting %}
|
||||
{% load truncatepath %}
|
||||
{% block body %}
|
||||
{% if not static_generation %}
|
||||
<form novalidate action="/" method="get" class="pf-c-form">
|
||||
<div class="pf-l-flex">
|
||||
<div class="pf-l-flex">
|
||||
<div class="pf-c-form__group pf-m-inline">
|
||||
<div class="pf-l-flex__item pf-m-flex-1">
|
||||
<label class="pf-c-form__label" for="ansible_version">
|
||||
<span class="pf-c-form__label-text">Ansible version</span>
|
||||
</label>
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<input class="pf-c-form-control" type="text" id="ansible_version" name="ansible_version" value="{% if search_form.ansible_version.value is not null %}{{ search_form.ansible_version.value }}{% endif %}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-flex__item pf-m-flex-1">
|
||||
<label class="pf-c-form__label" for="controller">
|
||||
<span class="pf-c-form__label-text">Controller</span>
|
||||
</label>
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<input class="pf-c-form-control" type="text" id="controller" name="controller" value="{% if search_form.controller.value is not null %}{{ search_form.controller.value }}{% endif %}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-flex__item pf-m-flex-1">
|
||||
<label class="pf-c-form__label" for="name">
|
||||
<span class="pf-c-form__label-text">Name</span>
|
||||
</label>
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<input class="pf-c-form-control" type="text" id="name" name="name" value="{% if search_form.name.value is not null %}{{ search_form.name.value }}{% endif %}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-flex__item pf-m-flex-1">
|
||||
<label class="pf-c-form__label" for="path">
|
||||
<span class="pf-c-form__label-text">Path</span>
|
||||
</label>
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<input class="pf-c-form-control" type="text" id="path" name="path" value="{% if search_form.path.value is not null %}{{ search_form.path.value }}{% endif %}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-flex__item pf-m-flex-1">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="label">
|
||||
<span class="pf-c-form__label-text">Label</span>
|
||||
</label>
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<input class="pf-c-form-control" type="text" id="label" name="label" value="{% if search_form.label.value is not null %}{{ search_form.label.value }}{% endif %}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-flex__item">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="status">
|
||||
<span class="pf-c-form__label-text">Status</span>
|
||||
</label>
|
||||
<div class="pf-c-form__group pf-m-inline">
|
||||
<fieldset class="pf-c-form__fieldset" aria-labelledby="select-checkbox-expanded-label">
|
||||
{% for value, text in search_form.status.field.choices %}
|
||||
{% if value != "unknown" %}
|
||||
<label class="pf-c-check pf-c-select__menu-item" for="{{ value }}">
|
||||
{% if value in search_form.status.data %}
|
||||
<input class="pf-c-check__input" type="checkbox" id="{{ value }}" name="status" value="{{ value }}" checked />
|
||||
{% else %}
|
||||
<input class="pf-c-check__input" type="checkbox" id="{{ value }}" name="status" value="{{ value }}" />
|
||||
{% endif %}
|
||||
<span class="pf-c-check__label">{{ value }}</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-flex__item">
|
||||
<div class="pf-c-form__actions">
|
||||
<button class="pf-c-button pf-m-primary" type="submit"><i class="fas fa-search"></i> Search</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if search_query %}
|
||||
<div class="pf-l-flex__item">
|
||||
<div class="pf-c-form__actions">
|
||||
<a href="/">
|
||||
<button class="pf-c-button pf-m-plain pf-m-link" type="button" aria-label="Remove">
|
||||
<i class="fas fa-times" aria-hidden="true"></i> Clear filters
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-flex pf-m-align-right">
|
||||
<div class="pf-l-flex__item">
|
||||
<h1 class="pf-c-title pf-m-2xl"><i class="fas fa-clock"></i> Filter by date</h1>
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
<li><button class="pf-c-button pf-m-link" name="started_after" value="{% past_timestamp with minutes=60 %}" type="submit">Last 60 minutes</button></li>
|
||||
<li><button class="pf-c-button pf-m-link" name="started_after" value="{% past_timestamp with hours=24 %}" type="submit">Last 24 hours</button></li>
|
||||
<li><button class="pf-c-button pf-m-link" name="started_after" value="{% past_timestamp with days=7 %}" type="submit">Last 7 days</button></li>
|
||||
<li><button class="pf-c-button pf-m-link" name="started_after" value="{% past_timestamp with days=30 %}" type="submit">Last 30 days</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "partials/pagination.html" %}
|
||||
{% endif %}
|
||||
|
||||
<div class="pf-l-flex">
|
||||
<table class="pf-c-table pf-m-grid-md pf-m-compact" role="grid" aria-label="Playbook runs" id="playbooks">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content">Status</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content pf-c-table__sort">
|
||||
{% include "partials/sort_by_date.html" %}
|
||||
</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content pf-c-table__sort">
|
||||
{% include "partials/sort_by_duration.html" %}
|
||||
</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content">Ansible version</th>
|
||||
<th role="columnheader" scope="col">Controller</th>
|
||||
<th role="columnheader" scope="col">Name (or path)</th>
|
||||
<th role="columnheader" scope="col">Labels</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content">Hosts</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content">Plays</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content">Tasks</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content">Results</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content">Files</th>
|
||||
<th role="columnheader" scope="col" class="pf-m-fit-content">Records</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for playbook in data.results %}
|
||||
<tr role="row" class="pf-m-success">
|
||||
<td role="cell" data-label="Status" class="pf-c-table__icon pf-m-fit-content">
|
||||
{% if playbook.status == "completed" %}
|
||||
<div class="pf-c-alert pf-m-success pf-m-inline">
|
||||
{% elif playbook.status == "failed" %}
|
||||
<div class="pf-c-alert pf-m-danger pf-m-inline">
|
||||
{% elif playbook.status == "running" %}
|
||||
<div class="pf-c-alert pf-m-info pf-m-inline">
|
||||
{% else %}
|
||||
<div class="pf-c-alert pf-m-warning pf-m-inline">
|
||||
{% endif %}
|
||||
{% include "partials/playbook_status_icon.html" with status=playbook.status %}
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell" data-label="Date" class="pf-m-fit-content">
|
||||
<span title="Date at which the playbook started">
|
||||
{{ playbook.started | format_date }}
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell" data-label="Duration" class="pf-m-fit-content">
|
||||
<span title="Duration of the playbook (HH:MM:SS.ms)">
|
||||
{{ playbook.duration | format_duration }}
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell" data-label="Ansible version" class="pf-m-fit-content">
|
||||
{{ playbook.ansible_version }}
|
||||
</td>
|
||||
<td role="cell" data-label="Controller" class="pf-m-fit-content">
|
||||
{{ playbook.controller }}
|
||||
</td>
|
||||
<td role="cell" data-label="Name (or path)" class="pf-m-fit-content">
|
||||
{% if static_generation %}
|
||||
<a href="{% if page != "index" %}../{% endif %}playbooks/{{ playbook.id }}.html" title="{{ playbook.path }}">
|
||||
{% else %}
|
||||
{% if playbook.status == "failed" %}
|
||||
<a href="{% url 'ui:playbook' playbook.id %}?status=failed&status=unreachable#results">
|
||||
{% else %}
|
||||
<a href="{% url 'ui:playbook' playbook.id %}">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if playbook.name is not None %}{{ playbook.name }}{% else %}{{ playbook.path | truncatepath:50 }}{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td role="cell" data-label="Labels" class="pf-m-wrap">
|
||||
<div class="pf-l-flex">
|
||||
{% for label in playbook.labels %}
|
||||
{% if not static_generation %}
|
||||
<a class="pf-c-button pf-m-secondary pf-c-label pf-m-compact" title="Search for this label" href="{% if page != 'index' %}../{% endif %}?label={{ label.name }}">
|
||||
{{ label.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="pf-c-badge pf-m-unread">{{ label.name }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell" data-label="Hosts" class="pf-m-fit-content">
|
||||
<a href="{% if page != "index" %}../{% endif %}playbooks/{{ playbook.id }}.html#hosts">{{ playbook.items.hosts }}</a>
|
||||
</td>
|
||||
<td role="cell" data-label="Plays" class="pf-m-fit-content">
|
||||
<a href="{% if page != "index" %}../{% endif %}playbooks/{{ playbook.id }}.html#plays">{{ playbook.items.plays }}</a>
|
||||
</td>
|
||||
<td role="cell" data-label="Tasks" class="pf-m-fit-content">
|
||||
<a href="{% if page != "index" %}../{% endif %}playbooks/{{ playbook.id }}.html#results">{{ playbook.items.tasks }}</a>
|
||||
</td>
|
||||
<td role="cell" data-label="Results" class="pf-m-fit-content">
|
||||
<a href="{% if page != "index" %}../{% endif %}playbooks/{{ playbook.id }}.html#results">{{ playbook.items.results }}</a>
|
||||
</td>
|
||||
<td role="cell" data-label="Files" class="pf-m-fit-content">
|
||||
<a href="{% if page != "index" %}../{% endif %}playbooks/{{ playbook.id }}.html#files">{{ playbook.items.files }}</a>
|
||||
</td>
|
||||
<td role="cell" data-label="Records" class="pf-m-fit-content">
|
||||
<a href="{% if page != "index" %}../{% endif %}playbooks/{{ playbook.id }}.html#records">{{ playbook.items.records }}</a>
|
||||
</td>
|
||||
<td role="cell" data-label="CLI arguments">
|
||||
<details id="cli-arguments-details">
|
||||
<summary><a>CLI arguments</a></summary>
|
||||
<table class="pf-c-table pf-m-compact pf-m-grid-md" role="grid" aria-label="cli-arguments" id="cli-arguments">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Argument</th>
|
||||
<th role="columnheader" scope="col">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for arg, value in playbook.arguments.items %}
|
||||
<tr role="row">
|
||||
<td role="cell" data-label="Argument" class="pf-m-fit-content" style="white-space: nowrap;">{{ arg }}</td>
|
||||
<td role="cell" data-label="Value" class="pf-m-fit-content">{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not static_generation %}
|
||||
</form>
|
||||
{% include "partials/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user