mirror of
https://github.com/ansible/awx.git
synced 2026-04-11 13:09:21 -02:30
Compare commits
202 Commits
21.1.0
...
revert-124
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ba8dd4d98 | ||
|
|
33e445f4f6 | ||
|
|
9bcb60d9e0 | ||
|
|
40109d58c7 | ||
|
|
2ef3f5f9e8 | ||
|
|
389c4a3180 | ||
|
|
cbb019ed09 | ||
|
|
bf5dfdaba7 | ||
|
|
0f7f8af9b8 | ||
|
|
0237402390 | ||
|
|
84d7fa882d | ||
|
|
cd2fae3471 | ||
|
|
8be64145f9 | ||
|
|
23d28fb4c8 | ||
|
|
aeffd6f393 | ||
|
|
ab6b4bad03 | ||
|
|
769c253ac2 | ||
|
|
8031b3d402 | ||
|
|
bd93ac7edd | ||
|
|
37ff9913d3 | ||
|
|
9cb44a7e52 | ||
|
|
6279295541 | ||
|
|
de17cff39c | ||
|
|
22ca49e673 | ||
|
|
008a4b4d30 | ||
|
|
8d4089c7f3 | ||
|
|
e296d0adad | ||
|
|
401b30b3ed | ||
|
|
20cc54694c | ||
|
|
e6ec0952fb | ||
|
|
db1dec3a98 | ||
|
|
1853d3850e | ||
|
|
1e57c84383 | ||
|
|
3cf120c6a7 | ||
|
|
fd671ecc9d | ||
|
|
a0d5f1fb03 | ||
|
|
ff882a322b | ||
|
|
b70231f7d0 | ||
|
|
93d1aa0a9d | ||
|
|
c586f8bbc6 | ||
|
|
26912a06d1 | ||
|
|
218a3d333b | ||
|
|
d2013bd416 | ||
|
|
6a3f9690b0 | ||
|
|
d59b6f834c | ||
|
|
cbea36745e | ||
|
|
ae7be525e1 | ||
|
|
5062ce1e61 | ||
|
|
566665ee8c | ||
|
|
96423af160 | ||
|
|
a01bef8d2c | ||
|
|
0522233892 | ||
|
|
63ea6bb5b3 | ||
|
|
c2715d7c29 | ||
|
|
783b744bdb | ||
|
|
f7982a0d64 | ||
|
|
2147ac226e | ||
|
|
6cc22786bc | ||
|
|
861a9f581e | ||
|
|
e57a8183ba | ||
|
|
8a7163ffad | ||
|
|
439b351c95 | ||
|
|
14afab918e | ||
|
|
ef8d4e73ae | ||
|
|
61f483ae32 | ||
|
|
21bed7473d | ||
|
|
31d8ddcf84 | ||
|
|
9419270897 | ||
|
|
f755d93a58 | ||
|
|
05df2ebad2 | ||
|
|
b44442c460 | ||
|
|
989b389ba4 | ||
|
|
5bd4aade0e | ||
|
|
470910b612 | ||
|
|
dbb81551c8 | ||
|
|
f7c5cb2979 | ||
|
|
babd6f0975 | ||
|
|
7bcceb7e98 | ||
|
|
c92619a2dc | ||
|
|
923cc671db | ||
|
|
db105c21e4 | ||
|
|
372aa36207 | ||
|
|
173318764b | ||
|
|
1dd535a859 | ||
|
|
e7d37b26f3 | ||
|
|
f4ef7d6927 | ||
|
|
7cbe112e4e | ||
|
|
c441db2aab | ||
|
|
fb292d9706 | ||
|
|
35a5f93182 | ||
|
|
116dc0c480 | ||
|
|
b87ba1c53d | ||
|
|
59691b71bb | ||
|
|
cc0bb3e401 | ||
|
|
7ef90bd9f4 | ||
|
|
f820c49b82 | ||
|
|
ac62d86f2a | ||
|
|
b9e67e7972 | ||
|
|
48a2ebd48c | ||
|
|
ee13ddd87d | ||
|
|
3fcf7429a3 | ||
|
|
51a8790d56 | ||
|
|
c231e4d05e | ||
|
|
987e5a084d | ||
|
|
70ac7b2920 | ||
|
|
bda335cb19 | ||
|
|
30c060cb27 | ||
|
|
9b0a2b0b76 | ||
|
|
2f82b75748 | ||
|
|
84fcd2ff00 | ||
|
|
3bc0c53e37 | ||
|
|
bc2dbcfce8 | ||
|
|
876edf54a3 | ||
|
|
b31bf8fab1 | ||
|
|
e8b2998578 | ||
|
|
8a92a01652 | ||
|
|
705f86f8cf | ||
|
|
9ab6a6d57e | ||
|
|
791eb4c1e1 | ||
|
|
870ca29388 | ||
|
|
816518cfab | ||
|
|
9e981583a6 | ||
|
|
d6fb8d6cd7 | ||
|
|
7dbf5f7138 | ||
|
|
aaec9487e6 | ||
|
|
96fa881df1 | ||
|
|
b7057fdc3e | ||
|
|
2679c99cad | ||
|
|
ea3a8d4912 | ||
|
|
63d9cd7b57 | ||
|
|
b692bbaa12 | ||
|
|
186af73e5d | ||
|
|
fddf292d47 | ||
|
|
1180634ba7 | ||
|
|
9abdafe101 | ||
|
|
48ebcd5918 | ||
|
|
fe6d0ce9cc | ||
|
|
62dabcae63 | ||
|
|
0b63af8d4d | ||
|
|
b05ebe9623 | ||
|
|
c836fafb61 | ||
|
|
96330f608d | ||
|
|
23aaf5b3ad | ||
|
|
a3e86dcd73 | ||
|
|
81b8028ea2 | ||
|
|
a4bfb032ff | ||
|
|
2704b202bf | ||
|
|
550d9d5e42 | ||
|
|
ab2d05a07d | ||
|
|
4543f6935f | ||
|
|
78d3d6dc94 | ||
|
|
02e7424f51 | ||
|
|
2d6ca4cbb1 | ||
|
|
e244644a1d | ||
|
|
d216457c09 | ||
|
|
20a1da61c0 | ||
|
|
bf7ab1ede7 | ||
|
|
3b6b449545 | ||
|
|
781cf531e6 | ||
|
|
9b7475247c | ||
|
|
44dc7f8d1d | ||
|
|
60eaf9e235 | ||
|
|
f5102ed24d | ||
|
|
309178e4e2 | ||
|
|
76ffdbb993 | ||
|
|
d8037618c8 | ||
|
|
e94e15977c | ||
|
|
f37951249f | ||
|
|
9191079dda | ||
|
|
fdd560747d | ||
|
|
faa5df19ca | ||
|
|
5f9326b131 | ||
|
|
8e389d40b4 | ||
|
|
e62c77e783 | ||
|
|
48b3a43ec2 | ||
|
|
5f783fd5ee | ||
|
|
e112cf93c2 | ||
|
|
d9f26a411e | ||
|
|
ea84e7a491 | ||
|
|
7fab619fed | ||
|
|
699a35b88a | ||
|
|
8095adb945 | ||
|
|
8d36712860 | ||
|
|
0db34d0498 | ||
|
|
7ab254e5e3 | ||
|
|
dd7ab459e2 | ||
|
|
33df2e8aa4 | ||
|
|
39b8fd433b | ||
|
|
c31d74100d | ||
|
|
3af89c1e2b | ||
|
|
1d35bba8c3 | ||
|
|
c3c3e24875 | ||
|
|
ab9c97b158 | ||
|
|
5e700c992d | ||
|
|
127016d36b | ||
|
|
d553c37d7d | ||
|
|
8a5e89e24b | ||
|
|
f02212b1fe | ||
|
|
973facebba | ||
|
|
bca6e00e37 | ||
|
|
b562d5cc88 | ||
|
|
dfde30798e |
17
.github/BOTMETA.yml
vendored
17
.github/BOTMETA.yml
vendored
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
files:
|
|
||||||
awx/ui/:
|
|
||||||
labels: component:ui
|
|
||||||
maintainers: $team_ui
|
|
||||||
awx/api/:
|
|
||||||
labels: component:api
|
|
||||||
maintainers: $team_api
|
|
||||||
awx/main/:
|
|
||||||
labels: component:api
|
|
||||||
maintainers: $team_api
|
|
||||||
installer/:
|
|
||||||
labels: component:installer
|
|
||||||
|
|
||||||
macros:
|
|
||||||
team_api: wwitzel3 matburt chrismeyersfsu cchurch AlanCoding ryanpetrello rooftopcellist
|
|
||||||
team_ui: jlmitch5 jaredevantabor mabashian marshmalien benthomasson jakemcdermott
|
|
||||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1 +0,0 @@
|
|||||||
workflows/e2e_test.yml @tiagodread @shanemcd @jakemcdermott
|
|
||||||
26
.github/ISSUE_TEMPLATE.md
vendored
26
.github/ISSUE_TEMPLATE.md
vendored
@@ -6,17 +6,37 @@ practices regarding responsible disclosure, see
|
|||||||
https://www.ansible.com/security
|
https://www.ansible.com/security
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
PLEASE DO NOT USE A BLANK TEMPLATE IN THE AWX REPO.
|
||||||
|
This is a legacy template used for internal testing ONLY.
|
||||||
|
|
||||||
|
Any issues opened will this template will be automatically closed.
|
||||||
|
|
||||||
|
Instead use the bug or feature request.
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
##### ISSUE TYPE
|
##### ISSUE TYPE
|
||||||
<!--- Pick one below and delete the rest: -->
|
<!--- Pick one below and delete the rest: -->
|
||||||
- Bug Report
|
- Breaking Change
|
||||||
- Feature Idea
|
- New or Enhanced Feature
|
||||||
- Documentation
|
- Bug or Docs Fix
|
||||||
|
|
||||||
|
|
||||||
##### COMPONENT NAME
|
##### COMPONENT NAME
|
||||||
<!-- Pick the area of AWX for this issue, you can have multiple, delete the rest: -->
|
<!-- Pick the area of AWX for this issue, you can have multiple, delete the rest: -->
|
||||||
- API
|
- API
|
||||||
- UI
|
- UI
|
||||||
- Collection
|
- Collection
|
||||||
|
- Docs
|
||||||
|
- CLI
|
||||||
|
- Other
|
||||||
|
|
||||||
|
|
||||||
##### SUMMARY
|
##### SUMMARY
|
||||||
<!-- Briefly describe the problem. -->
|
<!-- Briefly describe the problem. -->
|
||||||
|
|||||||
17
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
17
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,13 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Create a report to help us improve
|
description: "🐞 Create a report to help us improve"
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Issues are for **concrete, actionable bugs and feature requests** only. For debugging help or technical support, please use:
|
Bug Report issues are for **concrete, actionable bugs** only.
|
||||||
- The #ansible-awx channel on irc.libera.chat
|
For debugging help or technical support, please see the [Get Involved section of our README](https://github.com/ansible/awx#get-involved)
|
||||||
- The awx project mailing list, https://groups.google.com/forum/#!forum/awx-project
|
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
@@ -24,7 +23,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
label: Summary
|
label: Bug Summary
|
||||||
description: Briefly describe the problem.
|
description: Briefly describe the problem.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
@@ -45,6 +44,9 @@ body:
|
|||||||
- label: UI
|
- label: UI
|
||||||
- label: API
|
- label: API
|
||||||
- label: Docs
|
- label: Docs
|
||||||
|
- label: Collection
|
||||||
|
- label: CLI
|
||||||
|
- label: Other
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: awx-install-method
|
id: awx-install-method
|
||||||
@@ -57,9 +59,8 @@ body:
|
|||||||
- minikube
|
- minikube
|
||||||
- openshift
|
- openshift
|
||||||
- minishift
|
- minishift
|
||||||
- docker on linux
|
- docker development environment
|
||||||
- docker for mac
|
- N/A
|
||||||
- boot2docker
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
12
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
12
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: For debugging help or technical support
|
||||||
|
url: https://github.com/ansible/awx#get-involved
|
||||||
|
about: For general debugging or technical support please see the Get Involved section of our readme.
|
||||||
|
- name: 📝 Ansible Code of Conduct
|
||||||
|
url: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html?utm_medium=github&utm_source=issue_template_chooser
|
||||||
|
about: AWX uses the Ansible Code of Conduct; ❤ Be nice to other members of the community. ☮ Behave.
|
||||||
|
- name: 💼 For Enterprise
|
||||||
|
url: https://www.ansible.com/products/engine?utm_medium=github&utm_source=issue_template_chooser
|
||||||
|
about: Red Hat offers support for the Ansible Automation Platform
|
||||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
name: "✨ Feature request"
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
|
|
||||||
---
|
|
||||||
<!-- Issues are for **concrete, actionable bugs and feature requests** only - if you're just asking for debugging help or technical support, please use:
|
|
||||||
|
|
||||||
- http://web.libera.chat/?channels=#ansible-awx
|
|
||||||
- https://groups.google.com/forum/#!forum/awx-project
|
|
||||||
|
|
||||||
We have to limit this because of limited volunteer time to respond to issues! -->
|
|
||||||
|
|
||||||
##### ISSUE TYPE
|
|
||||||
- Feature Idea
|
|
||||||
|
|
||||||
##### SUMMARY
|
|
||||||
<!-- Briefly describe the problem or desired enhancement. -->
|
|
||||||
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: ✨ Feature request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Feature Request issues are for **feature requests** only.
|
||||||
|
For debugging help or technical support, please see the [Get Involved section of our README](https://github.com/ansible/awx#get-involved)
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: Please confirm the following
|
||||||
|
options:
|
||||||
|
- label: I agree to follow this project's [code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html).
|
||||||
|
required: true
|
||||||
|
- label: I have checked the [current issues](https://github.com/ansible/awx/issues) for duplicates.
|
||||||
|
required: true
|
||||||
|
- label: I understand that AWX is open source software provided for free and that I might not receive a timely response.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Feature Summary
|
||||||
|
description: Briefly describe the desired enhancement.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: components
|
||||||
|
attributes:
|
||||||
|
label: Select the relevant components
|
||||||
|
options:
|
||||||
|
- label: UI
|
||||||
|
- label: API
|
||||||
|
- label: Docs
|
||||||
|
- label: Collection
|
||||||
|
- label: CLI
|
||||||
|
- label: Other
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
---
|
|
||||||
name: "\U0001F525 Security bug report"
|
|
||||||
about: How to report security vulnerabilities
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For all security related bugs, email security@ansible.com instead of using this issue tracker and you will receive a prompt response.
|
|
||||||
|
|
||||||
For more information on the Ansible community's practices regarding responsible disclosure, see https://www.ansible.com/security
|
|
||||||
9
.github/LABEL_MAP.md
vendored
9
.github/LABEL_MAP.md
vendored
@@ -1,9 +0,0 @@
|
|||||||
Bug Report: type:bug
|
|
||||||
Bugfix Pull Request: type:bug
|
|
||||||
Feature Request: type:enhancement
|
|
||||||
Feature Pull Request: type:enhancement
|
|
||||||
UI: component:ui
|
|
||||||
API: component:api
|
|
||||||
Installer: component:installer
|
|
||||||
Docs Pull Request: component:docs
|
|
||||||
Documentation: component:docs
|
|
||||||
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,11 +1,3 @@
|
|||||||
<!--- changelog-entry
|
|
||||||
# Fill in 'msg' below to have an entry automatically added to the next release changelog.
|
|
||||||
# Leaving 'msg' blank will not generate a changelog entry for this PR.
|
|
||||||
# Please ensure this is a simple (and readable) one-line string.
|
|
||||||
---
|
|
||||||
msg: ""
|
|
||||||
-->
|
|
||||||
|
|
||||||
##### SUMMARY
|
##### SUMMARY
|
||||||
<!--- Describe the change, including rationale and design decisions -->
|
<!--- Describe the change, including rationale and design decisions -->
|
||||||
|
|
||||||
@@ -17,15 +9,18 @@ the change does.
|
|||||||
|
|
||||||
##### ISSUE TYPE
|
##### ISSUE TYPE
|
||||||
<!--- Pick one below and delete the rest: -->
|
<!--- Pick one below and delete the rest: -->
|
||||||
- Feature Pull Request
|
- Breaking Change
|
||||||
- Bugfix Pull Request
|
- New or Enhanced Feature
|
||||||
- Docs Pull Request
|
- Bug or Docs Fix
|
||||||
|
|
||||||
##### COMPONENT NAME
|
##### COMPONENT NAME
|
||||||
<!--- Name of the module/plugin/module/task -->
|
<!--- Name of the module/plugin/module/task -->
|
||||||
- API
|
- API
|
||||||
- UI
|
- UI
|
||||||
- Collection
|
- Collection
|
||||||
|
- CLI
|
||||||
|
- Docs
|
||||||
|
- Other
|
||||||
|
|
||||||
##### AWX VERSION
|
##### AWX VERSION
|
||||||
<!--- Paste verbatim output from `make VERSION` between quotes below -->
|
<!--- Paste verbatim output from `make VERSION` between quotes below -->
|
||||||
|
|||||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -13,7 +13,6 @@ updates:
|
|||||||
- "kialam"
|
- "kialam"
|
||||||
- "mabashian"
|
- "mabashian"
|
||||||
- "marshmalien"
|
- "marshmalien"
|
||||||
- "nixocio"
|
|
||||||
labels:
|
labels:
|
||||||
- "component:ui"
|
- "component:ui"
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
|||||||
8
.github/issue_labeler.yml
vendored
8
.github/issue_labeler.yml
vendored
@@ -1,12 +1,16 @@
|
|||||||
needs_triage:
|
needs_triage:
|
||||||
- '.*'
|
- '.*'
|
||||||
"type:bug":
|
"type:bug":
|
||||||
- "Please confirm the following"
|
- "Bug Summary"
|
||||||
"type:enhancement":
|
"type:enhancement":
|
||||||
- "Feature Idea"
|
- "Feature Summary"
|
||||||
"component:ui":
|
"component:ui":
|
||||||
- "\\[X\\] UI"
|
- "\\[X\\] UI"
|
||||||
"component:api":
|
"component:api":
|
||||||
- "\\[X\\] API"
|
- "\\[X\\] API"
|
||||||
"component:docs":
|
"component:docs":
|
||||||
- "\\[X\\] Docs"
|
- "\\[X\\] Docs"
|
||||||
|
"component:awx_collection":
|
||||||
|
- "\\[X\\] Collection"
|
||||||
|
"component:cli":
|
||||||
|
- "\\[X\\] awxkit"
|
||||||
|
|||||||
7
.github/pr_labeler.yml
vendored
7
.github/pr_labeler.yml
vendored
@@ -10,5 +10,10 @@
|
|||||||
"component:cli":
|
"component:cli":
|
||||||
- any: ["awxkit/**/*"]
|
- any: ["awxkit/**/*"]
|
||||||
|
|
||||||
"component:collection":
|
"component:awx_collection":
|
||||||
- any: ["awx_collection/**/*"]
|
- any: ["awx_collection/**/*"]
|
||||||
|
|
||||||
|
"dependencies":
|
||||||
|
- any: ["awx/ui/package.json"]
|
||||||
|
- any: ["awx/requirements/*.txt"]
|
||||||
|
- any: ["awx/requirements/requirements.in"]
|
||||||
|
|||||||
110
.github/triage_replies.md
vendored
110
.github/triage_replies.md
vendored
@@ -3,47 +3,109 @@
|
|||||||
- Hello, we think your question is answered in our FAQ. Does this: https://www.ansible.com/products/awx-project/faq cover your question?
|
- Hello, we think your question is answered in our FAQ. Does this: https://www.ansible.com/products/awx-project/faq cover your question?
|
||||||
- You can find the latest documentation here: https://docs.ansible.com/automation-controller/latest/html/userguide/index.html
|
- You can find the latest documentation here: https://docs.ansible.com/automation-controller/latest/html/userguide/index.html
|
||||||
|
|
||||||
## Visit our mailing list
|
|
||||||
- Hello, your question seems like a good one to ask on our mailing list at https://groups.google.com/g/awx-project. You can also join #ansible-awx on https://libera.chat/ and ask your question there.
|
|
||||||
|
|
||||||
## Create an issue
|
|
||||||
- Hello, thanks for reaching out on list. We think this merits an issue on our Github, https://github.com/ansible/awx/issues. If you could open an issue up on Github it will get tagged and integrated into our planning and workflow. All future work will be tracked there.
|
|
||||||
|
|
||||||
## Create a Pull Request
|
## PRs/Issues
|
||||||
- Hello, we think your idea is good, please consider contributing a PR for this, following our contributing guidelines: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
|
|
||||||
|
|
||||||
## Give us more info
|
### Visit our mailing list
|
||||||
- Hello, we'd love to help but we need a little more information about the problem you're having. Screenshots, log outputs, or any reproducers would be very helpful.
|
- Hello, this appears to be less of a bug report or feature request and more of a question. Could you please ask this on our mailing list? See https://github.com/ansible/awx/#get-involved for information for ways to connect with us.
|
||||||
|
|
||||||
## Receptor
|
### Denied Submission
|
||||||
|
|
||||||
|
- Hi! \
|
||||||
|
\
|
||||||
|
Thanks very much for your submission to AWX. It means a lot to us that you have taken time to contribute. \
|
||||||
|
\
|
||||||
|
At this time we do not want to merge this PR. Our reasons for this are: \
|
||||||
|
\
|
||||||
|
(A) INSERT ITEM HERE \
|
||||||
|
\
|
||||||
|
Please know that we are always up for discussion but this project is very active. Because of this, we're unlikely to see comments made on closed PRs, and we lock them after some time. If you or anyone else has any further questions, please let us know by using any of the communication methods listed in the page below: \
|
||||||
|
\
|
||||||
|
https://github.com/ansible/awx/#get-involved \
|
||||||
|
\
|
||||||
|
In the future, sometimes starting a discussion on the development list prior to implementing a feature can make getting things included a little easier, but it is not always necessary. \
|
||||||
|
\
|
||||||
|
Thank you once again for this and your interest in AWX!
|
||||||
|
|
||||||
|
|
||||||
|
### No Progress Issue
|
||||||
|
- Hi! \
|
||||||
|
\
|
||||||
|
Thank you very much for for this issue. It means a lot to us that you have taken time to contribute by opening this report. \
|
||||||
|
\
|
||||||
|
On this issue, there were comments added but it has been some time since then without response. At this time we are closing this issue. If you get time to address the comments we can reopen the issue if you can contact us by using any of the communication methods listed in the page below: \
|
||||||
|
\
|
||||||
|
https://github.com/ansible/awx/#get-involved \
|
||||||
|
\
|
||||||
|
Thank you once again for this and your interest in AWX!
|
||||||
|
|
||||||
|
|
||||||
|
### No Progress PR
|
||||||
|
- Hi! \
|
||||||
|
\
|
||||||
|
Thank you very much for your submission to AWX. It means a lot to us that you have taken time to contribute. \
|
||||||
|
\
|
||||||
|
On this PR, changes were requested but it has been some time since then. We think this PR has merit but without the requested changes we are unable to merge it. At this time we are closing your PR. If you get time to address the changes you are welcome to open another PR or we can reopen this PR upon request if you contact us by using any of the communication methods listed in the page below: \
|
||||||
|
\
|
||||||
|
https://github.com/ansible/awx/#get-involved \
|
||||||
|
\
|
||||||
|
Thank you once again for this and your interest in AWX!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Common
|
||||||
|
|
||||||
|
### Give us more info
|
||||||
|
- Hello, we'd love to help, but we need a little more information about the problem you're having. Screenshots, log outputs, or any reproducers would be very helpful.
|
||||||
|
|
||||||
|
### Code of Conduct
|
||||||
|
- Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html
|
||||||
|
|
||||||
|
### EE Contents / Community General
|
||||||
|
- Hello. The awx-ee contains the collections and dependencies needed for supported AWX features to function. Anything beyond that (like the community.general package) will require you to build your own EE. For information on how to do that, see https://ansible-builder.readthedocs.io/en/stable/ \
|
||||||
|
\
|
||||||
|
The Ansible Community is looking at building an EE that corresponds to all of the collections inside the ansible package. That may help you if and when it happens; see https://github.com/ansible-community/community-topics/issues/31 for details.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Mailing List Triage
|
||||||
|
|
||||||
|
### Create an issue
|
||||||
|
- Hello, thanks for reaching out on list. We think this merits an issue on our Github, https://github.com/ansible/awx/issues. If you could open an issue up on Github it will get tagged and integrated into our planning and workflow. All future work will be tracked there. Issues should include as much information as possible, including screenshots, log outputs, or any reproducers.
|
||||||
|
|
||||||
|
### Create a Pull Request
|
||||||
|
- Hello, we think your idea is good! Please consider contributing a PR for this following our contributing guidelines: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
|
||||||
|
|
||||||
|
### Receptor
|
||||||
- You can find the receptor docs here: https://receptor.readthedocs.io/en/latest/
|
- You can find the receptor docs here: https://receptor.readthedocs.io/en/latest/
|
||||||
- Hello, your issue seems related to receptor, could you please open an issue in the receptor repository? https://github.com/ansible/receptor. Thanks!
|
- Hello, your issue seems related to receptor. Could you please open an issue in the receptor repository? https://github.com/ansible/receptor. Thanks!
|
||||||
|
|
||||||
## Ansible Engine not AWX
|
### Ansible Engine not AWX
|
||||||
- Hello, your question seems to be about Ansible development, not about AWX. Try asking on the Ansible-devel specific mailing list: https://groups.google.com/g/ansible-devel
|
- Hello, your question seems to be about Ansible development, not about AWX. Try asking on the Ansible-devel specific mailing list: https://groups.google.com/g/ansible-devel
|
||||||
- Hello, your question seems to be about using Ansible, not about AWX. https://groups.google.com/g/ansible-project is the best place to visit for user questions about Ansible. Thanks!
|
- Hello, your question seems to be about using Ansible, not about AWX. https://groups.google.com/g/ansible-project is the best place to visit for user questions about Ansible. Thanks!
|
||||||
|
|
||||||
## Ansible Galaxy not AWX
|
### Ansible Galaxy not AWX
|
||||||
- Hey there, that sounds like an FAQ question, did this: https://www.ansible.com/products/awx-project/faq cover your question?
|
- Hey there. That sounds like an FAQ question. Did this: https://www.ansible.com/products/awx-project/faq cover your question?
|
||||||
|
|
||||||
## Contributing Guidelines
|
### Contributing Guidelines
|
||||||
- AWX: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
|
- AWX: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
|
||||||
- AWX-Operator: https://github.com/ansible/awx-operator/blob/devel/CONTRIBUTING.md
|
- AWX-Operator: https://github.com/ansible/awx-operator/blob/devel/CONTRIBUTING.md
|
||||||
|
|
||||||
## Code of Conduct
|
### AWX Release
|
||||||
- Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html
|
Subject: Announcing AWX Xa.Ya.za and AWX-Operator Xb.Yb.zb
|
||||||
|
|
||||||
## AWX Release
|
- Hi all, \
|
||||||
- Hi all,\
|
|
||||||
\
|
\
|
||||||
We're happy to announce that the next release of AWX, version 21.0.0 is now available!\
|
We're happy to announce that the next release of AWX, version <b>`Xa.Ya.za`</b> is now available! \
|
||||||
In addition AWX Operator version 0.21.0 has also been release!\
|
In addition AWX Operator version <b>`Xb.Yb.zb`</b> has also been released! \
|
||||||
\
|
\
|
||||||
Please see the releases pages for more details:\
|
Please see the releases pages for more details: \
|
||||||
AWX: https://github.com/ansible/awx/releases/tag/21.0.0\
|
AWX: https://github.com/ansible/awx/releases/tag/Xa.Ya.za \
|
||||||
Operator: https://github.com/ansible/awx-operator/releases/tag/0.20.1\
|
Operator: https://github.com/ansible/awx-operator/releases/tag/Xb.Yb.zb \
|
||||||
\
|
\
|
||||||
The AWX team.
|
The AWX team.
|
||||||
|
|
||||||
## Try latest version
|
## Try latest version
|
||||||
- Hello, this issue pertains to an older version of AWX. Try upgrading to the lastest version and see if that resolves your issue.
|
- Hello, this issue pertains to an older version of AWX. Try upgrading to the latest version and let us know if that resolves your issue.
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -113,7 +113,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install playbook dependencies
|
- name: Install playbook dependencies
|
||||||
run: |
|
run: |
|
||||||
python3 -m pip install docker
|
python3 -m pip install docker setuptools_scm
|
||||||
|
|
||||||
- name: Build AWX image
|
- name: Build AWX image
|
||||||
working-directory: awx
|
working-directory: awx
|
||||||
|
|||||||
4
.github/workflows/promote.yml
vendored
4
.github/workflows/promote.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python${{ env.py_version }} -m pip install wheel twine
|
python${{ env.py_version }} -m pip install wheel twine setuptools-scm
|
||||||
|
|
||||||
- name: Set official collection namespace
|
- name: Set official collection namespace
|
||||||
run: echo collection_namespace=awx >> $GITHUB_ENV
|
run: echo collection_namespace=awx >> $GITHUB_ENV
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build collection and publish to galaxy
|
- name: Build collection and publish to galaxy
|
||||||
run: |
|
run: |
|
||||||
COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection
|
COLLECTION_TEMPLATE_VERSION=true COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection
|
||||||
ansible-galaxy collection publish \
|
ansible-galaxy collection publish \
|
||||||
--token=${{ secrets.GALAXY_TOKEN }} \
|
--token=${{ secrets.GALAXY_TOKEN }} \
|
||||||
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz
|
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz
|
||||||
|
|||||||
15
.github/workflows/stage.yml
vendored
15
.github/workflows/stage.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install playbook dependencies
|
- name: Install playbook dependencies
|
||||||
run: |
|
run: |
|
||||||
python3 -m pip install docker
|
python3 -m pip install docker setuptools_scm
|
||||||
|
|
||||||
- name: Build and stage AWX
|
- name: Build and stage AWX
|
||||||
working-directory: awx
|
working-directory: awx
|
||||||
@@ -100,23 +100,10 @@ jobs:
|
|||||||
AWX_TEST_IMAGE: ${{ github.repository }}
|
AWX_TEST_IMAGE: ${{ github.repository }}
|
||||||
AWX_TEST_VERSION: ${{ github.event.inputs.version }}
|
AWX_TEST_VERSION: ${{ github.event.inputs.version }}
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
uses: shanemcd/simple-changelog-generator@v1
|
|
||||||
id: changelog
|
|
||||||
with:
|
|
||||||
repo: "${{ github.repository }}"
|
|
||||||
|
|
||||||
- name: Write changelog to file
|
|
||||||
run: |
|
|
||||||
cat << 'EOF' > /tmp/awx-changelog
|
|
||||||
${{ steps.changelog.outputs.changelog }}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Create draft release for AWX
|
- name: Create draft release for AWX
|
||||||
working-directory: awx
|
working-directory: awx
|
||||||
run: |
|
run: |
|
||||||
ansible-playbook -v tools/ansible/stage.yml \
|
ansible-playbook -v tools/ansible/stage.yml \
|
||||||
-e changelog_path=/tmp/awx-changelog \
|
|
||||||
-e repo=${{ github.repository }} \
|
-e repo=${{ github.repository }} \
|
||||||
-e awx_image=ghcr.io/${{ github.repository }} \
|
-e awx_image=ghcr.io/${{ github.repository }} \
|
||||||
-e version=${{ github.event.inputs.version }} \
|
-e version=${{ github.event.inputs.version }} \
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -38,7 +38,6 @@ awx/ui/build
|
|||||||
awx/ui/.env.local
|
awx/ui/.env.local
|
||||||
awx/ui/instrumented
|
awx/ui/instrumented
|
||||||
rsyslog.pid
|
rsyslog.pid
|
||||||
tools/prometheus
|
|
||||||
tools/docker-compose/ansible/awx_dump.sql
|
tools/docker-compose/ansible/awx_dump.sql
|
||||||
tools/docker-compose/Dockerfile
|
tools/docker-compose/Dockerfile
|
||||||
tools/docker-compose/_build
|
tools/docker-compose/_build
|
||||||
@@ -154,6 +153,9 @@ use_dev_supervisor.txt
|
|||||||
/sanity/
|
/sanity/
|
||||||
/awx_collection_build/
|
/awx_collection_build/
|
||||||
|
|
||||||
|
# Setup for metrics gathering
|
||||||
|
tools/prometheus/prometheus.yml
|
||||||
|
|
||||||
.idea/*
|
.idea/*
|
||||||
*.unison.tmp
|
*.unison.tmp
|
||||||
*.#
|
*.#
|
||||||
|
|||||||
41
Makefile
41
Makefile
@@ -5,8 +5,8 @@ NPM_BIN ?= npm
|
|||||||
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
||||||
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
MANAGEMENT_COMMAND ?= awx-manage
|
MANAGEMENT_COMMAND ?= awx-manage
|
||||||
VERSION := $(shell $(PYTHON) setup.py --version)
|
VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py)
|
||||||
COLLECTION_VERSION := $(shell $(PYTHON) setup.py --version | cut -d . -f 1-3)
|
COLLECTION_VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3)
|
||||||
|
|
||||||
# NOTE: This defaults the container image version to the branch that's active
|
# NOTE: This defaults the container image version to the branch that's active
|
||||||
COMPOSE_TAG ?= $(GIT_BRANCH)
|
COMPOSE_TAG ?= $(GIT_BRANCH)
|
||||||
@@ -17,6 +17,10 @@ KEYCLOAK ?= false
|
|||||||
LDAP ?= false
|
LDAP ?= false
|
||||||
# If set to true docker-compose will also start a splunk instance
|
# If set to true docker-compose will also start a splunk instance
|
||||||
SPLUNK ?= false
|
SPLUNK ?= false
|
||||||
|
# If set to true docker-compose will also start a prometheus instance
|
||||||
|
PROMETHEUS ?= false
|
||||||
|
# If set to true docker-compose will also start a grafana instance
|
||||||
|
GRAFANA ?= false
|
||||||
|
|
||||||
VENV_BASE ?= /var/lib/awx/venv
|
VENV_BASE ?= /var/lib/awx/venv
|
||||||
|
|
||||||
@@ -45,7 +49,7 @@ I18N_FLAG_FILE = .i18n_built
|
|||||||
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
|
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
|
||||||
develop refresh adduser migrate dbchange \
|
develop refresh adduser migrate dbchange \
|
||||||
receiver test test_unit test_coverage coverage_html \
|
receiver test test_unit test_coverage coverage_html \
|
||||||
dev_build release_build sdist \
|
sdist \
|
||||||
ui-release ui-devel \
|
ui-release ui-devel \
|
||||||
VERSION PYTHON_VERSION docker-compose-sources \
|
VERSION PYTHON_VERSION docker-compose-sources \
|
||||||
.git/hooks/pre-commit
|
.git/hooks/pre-commit
|
||||||
@@ -200,7 +204,7 @@ uwsgi: collectstatic
|
|||||||
--logformat "%(addr) %(method) %(uri) - %(proto) %(status)"
|
--logformat "%(addr) %(method) %(uri) - %(proto) %(status)"
|
||||||
|
|
||||||
awx-autoreload:
|
awx-autoreload:
|
||||||
@/awx_devel/tools/docker-compose/awx-autoreload /awx_devel "$(DEV_RELOAD_COMMAND)"
|
@/awx_devel/tools/docker-compose/awx-autoreload /awx_devel/awx "$(DEV_RELOAD_COMMAND)"
|
||||||
|
|
||||||
daphne:
|
daphne:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
@@ -269,7 +273,7 @@ api-lint:
|
|||||||
yamllint -s .
|
yamllint -s .
|
||||||
|
|
||||||
awx-link:
|
awx-link:
|
||||||
[ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/setup.py egg_info_dev
|
[ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/tools/scripts/egg_info_dev
|
||||||
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/$(PYTHON)/site-packages/awx.egg-link
|
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/$(PYTHON)/site-packages/awx.egg-link
|
||||||
|
|
||||||
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
|
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
|
||||||
@@ -288,6 +292,7 @@ COLLECTION_TEST_TARGET ?=
|
|||||||
COLLECTION_PACKAGE ?= awx
|
COLLECTION_PACKAGE ?= awx
|
||||||
COLLECTION_NAMESPACE ?= awx
|
COLLECTION_NAMESPACE ?= awx
|
||||||
COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE)
|
COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE)
|
||||||
|
COLLECTION_TEMPLATE_VERSION ?= false
|
||||||
|
|
||||||
test_collection:
|
test_collection:
|
||||||
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
|
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
|
||||||
@@ -315,7 +320,7 @@ awx_collection_build: $(shell find awx_collection -type f)
|
|||||||
-e collection_package=$(COLLECTION_PACKAGE) \
|
-e collection_package=$(COLLECTION_PACKAGE) \
|
||||||
-e collection_namespace=$(COLLECTION_NAMESPACE) \
|
-e collection_namespace=$(COLLECTION_NAMESPACE) \
|
||||||
-e collection_version=$(COLLECTION_VERSION) \
|
-e collection_version=$(COLLECTION_VERSION) \
|
||||||
-e '{"awx_template_version":false}'
|
-e '{"awx_template_version": $(COLLECTION_TEMPLATE_VERSION)}'
|
||||||
ansible-galaxy collection build awx_collection_build --force --output-path=awx_collection_build
|
ansible-galaxy collection build awx_collection_build --force --output-path=awx_collection_build
|
||||||
|
|
||||||
build_collection: awx_collection_build
|
build_collection: awx_collection_build
|
||||||
@@ -419,21 +424,13 @@ ui-test-general:
|
|||||||
$(NPM_BIN) run --prefix awx/ui pretest
|
$(NPM_BIN) run --prefix awx/ui pretest
|
||||||
$(NPM_BIN) run --prefix awx/ui/ test-general --runInBand
|
$(NPM_BIN) run --prefix awx/ui/ test-general --runInBand
|
||||||
|
|
||||||
# Build a pip-installable package into dist/ with a timestamped version number.
|
|
||||||
dev_build:
|
|
||||||
$(PYTHON) setup.py dev_build
|
|
||||||
|
|
||||||
# Build a pip-installable package into dist/ with the release version number.
|
|
||||||
release_build:
|
|
||||||
$(PYTHON) setup.py release_build
|
|
||||||
|
|
||||||
HEADLESS ?= no
|
HEADLESS ?= no
|
||||||
ifeq ($(HEADLESS), yes)
|
ifeq ($(HEADLESS), yes)
|
||||||
dist/$(SDIST_TAR_FILE):
|
dist/$(SDIST_TAR_FILE):
|
||||||
else
|
else
|
||||||
dist/$(SDIST_TAR_FILE): $(UI_BUILD_FLAG_FILE)
|
dist/$(SDIST_TAR_FILE): $(UI_BUILD_FLAG_FILE)
|
||||||
endif
|
endif
|
||||||
$(PYTHON) setup.py $(SDIST_COMMAND)
|
$(PYTHON) -m build -s
|
||||||
ln -sf $(SDIST_TAR_FILE) dist/awx.tar.gz
|
ln -sf $(SDIST_TAR_FILE) dist/awx.tar.gz
|
||||||
|
|
||||||
sdist: dist/$(SDIST_TAR_FILE)
|
sdist: dist/$(SDIST_TAR_FILE)
|
||||||
@@ -469,7 +466,9 @@ docker-compose-sources: .git/hooks/pre-commit
|
|||||||
-e minikube_container_group=$(MINIKUBE_CONTAINER_GROUP) \
|
-e minikube_container_group=$(MINIKUBE_CONTAINER_GROUP) \
|
||||||
-e enable_keycloak=$(KEYCLOAK) \
|
-e enable_keycloak=$(KEYCLOAK) \
|
||||||
-e enable_ldap=$(LDAP) \
|
-e enable_ldap=$(LDAP) \
|
||||||
-e enable_splunk=$(SPLUNK)
|
-e enable_splunk=$(SPLUNK) \
|
||||||
|
-e enable_prometheus=$(PROMETHEUS) \
|
||||||
|
-e enable_grafana=$(GRAFANA)
|
||||||
|
|
||||||
|
|
||||||
docker-compose: awx/projects docker-compose-sources
|
docker-compose: awx/projects docker-compose-sources
|
||||||
@@ -517,7 +516,7 @@ docker-clean:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
docker-clean-volumes: docker-compose-clean docker-compose-container-group-clean
|
docker-clean-volumes: docker-compose-clean docker-compose-container-group-clean
|
||||||
docker volume rm tools_awx_db
|
docker volume rm -f tools_awx_db tools_grafana_storage tools_prometheus_storage $(docker volume ls --filter name=tools_redis_socket_ -q)
|
||||||
|
|
||||||
docker-refresh: docker-clean docker-compose
|
docker-refresh: docker-clean docker-compose
|
||||||
|
|
||||||
@@ -528,14 +527,6 @@ docker-compose-elk: awx/projects docker-compose-sources
|
|||||||
docker-compose-cluster-elk: awx/projects docker-compose-sources
|
docker-compose-cluster-elk: awx/projects docker-compose-sources
|
||||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link-cluster.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link-cluster.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||||
|
|
||||||
prometheus:
|
|
||||||
docker volume create prometheus
|
|
||||||
docker run -d --rm --net=_sources_default --link=awx_1:awx1 --volume prometheus-storage:/prometheus --volume `pwd`/tools/prometheus:/etc/prometheus --name prometheus -p 9090:9090 prom/prometheus
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
docker volume create grafana
|
|
||||||
docker run -d --rm --net=_sources_default --volume grafana-storage:/var/lib/grafana --volume `pwd`/tools/grafana:/etc/grafana/provisioning --name grafana -p 3001:3000 grafana/grafana-enterprise
|
|
||||||
|
|
||||||
docker-compose-container-group:
|
docker-compose-container-group:
|
||||||
MINIKUBE_CONTAINER_GROUP=true make docker-compose
|
MINIKUBE_CONTAINER_GROUP=true make docker-compose
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,40 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from pkg_resources import get_distribution
|
|
||||||
|
|
||||||
__version__ = get_distribution('awx').version
|
def get_version():
|
||||||
|
version_from_file = get_version_from_file()
|
||||||
|
if version_from_file:
|
||||||
|
return version_from_file
|
||||||
|
else:
|
||||||
|
from setuptools_scm import get_version
|
||||||
|
|
||||||
|
version = get_version(root='..', relative_to=__file__)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def get_version_from_file():
|
||||||
|
vf = version_file()
|
||||||
|
if vf:
|
||||||
|
with open(vf, 'r') as file:
|
||||||
|
return file.read().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def version_file():
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
version_file = os.path.join(current_dir, '..', 'VERSION')
|
||||||
|
|
||||||
|
if os.path.exists(version_file):
|
||||||
|
return version_file
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
|
__version__ = pkg_resources.get_distribution('awx').version
|
||||||
|
except pkg_resources.DistributionNotFound:
|
||||||
|
__version__ = get_version()
|
||||||
|
|
||||||
__all__ = ['__version__']
|
__all__ = ['__version__']
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +52,6 @@ try:
|
|||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
MODE = 'production'
|
MODE = 'production'
|
||||||
|
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2073,7 +2073,7 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventorySource
|
model = InventorySource
|
||||||
fields = ('*', 'name', 'inventory', 'update_on_launch', 'update_cache_timeout', 'source_project', 'update_on_project_update') + (
|
fields = ('*', 'name', 'inventory', 'update_on_launch', 'update_cache_timeout', 'source_project') + (
|
||||||
'last_update_failed',
|
'last_update_failed',
|
||||||
'last_updated',
|
'last_updated',
|
||||||
) # Backwards compatibility.
|
) # Backwards compatibility.
|
||||||
@@ -2136,11 +2136,6 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
|
|||||||
raise serializers.ValidationError(_("Cannot use manual project for SCM-based inventory."))
|
raise serializers.ValidationError(_("Cannot use manual project for SCM-based inventory."))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_update_on_project_update(self, value):
|
|
||||||
if value and self.instance and self.instance.schedules.exists():
|
|
||||||
raise serializers.ValidationError(_("Setting not compatible with existing schedules."))
|
|
||||||
return value
|
|
||||||
|
|
||||||
def validate_inventory(self, value):
|
def validate_inventory(self, value):
|
||||||
if value and value.kind == 'smart':
|
if value and value.kind == 'smart':
|
||||||
raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart Inventory")})
|
raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart Inventory")})
|
||||||
@@ -2191,7 +2186,7 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
|
|||||||
if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None:
|
if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None:
|
||||||
raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")})
|
raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")})
|
||||||
else:
|
else:
|
||||||
redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path', 'update_on_project_update']))
|
redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path']))
|
||||||
if redundant_scm_fields:
|
if redundant_scm_fields:
|
||||||
raise serializers.ValidationError({"detail": _("Cannot set %s if not SCM type." % ' '.join(redundant_scm_fields))})
|
raise serializers.ValidationError({"detail": _("Cannot set %s if not SCM type." % ' '.join(redundant_scm_fields))})
|
||||||
|
|
||||||
@@ -2236,7 +2231,6 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri
|
|||||||
'source_project_update',
|
'source_project_update',
|
||||||
'custom_virtualenv',
|
'custom_virtualenv',
|
||||||
'instance_group',
|
'instance_group',
|
||||||
'-controller_node',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_related(self, obj):
|
def get_related(self, obj):
|
||||||
@@ -2311,7 +2305,6 @@ class InventoryUpdateDetailSerializer(InventoryUpdateSerializer):
|
|||||||
class InventoryUpdateListSerializer(InventoryUpdateSerializer, UnifiedJobListSerializer):
|
class InventoryUpdateListSerializer(InventoryUpdateSerializer, UnifiedJobListSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryUpdate
|
model = InventoryUpdate
|
||||||
fields = ('*', '-controller_node') # field removal undone by UJ serializer
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryUpdateCancelSerializer(InventoryUpdateSerializer):
|
class InventoryUpdateCancelSerializer(InventoryUpdateSerializer):
|
||||||
@@ -4480,7 +4473,10 @@ class NotificationTemplateSerializer(BaseSerializer):
|
|||||||
body = messages[event].get('body', {})
|
body = messages[event].get('body', {})
|
||||||
if body:
|
if body:
|
||||||
try:
|
try:
|
||||||
potential_body = json.loads(body)
|
rendered_body = (
|
||||||
|
sandbox.ImmutableSandboxedEnvironment(undefined=DescriptiveUndefined).from_string(body).render(JobNotificationMixin.context_stub())
|
||||||
|
)
|
||||||
|
potential_body = json.loads(rendered_body)
|
||||||
if not isinstance(potential_body, dict):
|
if not isinstance(potential_body, dict):
|
||||||
error_list.append(
|
error_list.append(
|
||||||
_("Webhook body for '{}' should be a json dictionary. Found type '{}'.".format(event, type(potential_body).__name__))
|
_("Webhook body for '{}' should be a json dictionary. Found type '{}'.".format(event, type(potential_body).__name__))
|
||||||
@@ -4683,8 +4679,14 @@ class SchedulePreviewSerializer(BaseSerializer):
|
|||||||
class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSerializer):
|
class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSerializer):
|
||||||
show_capabilities = ['edit', 'delete']
|
show_capabilities = ['edit', 'delete']
|
||||||
|
|
||||||
timezone = serializers.SerializerMethodField()
|
timezone = serializers.SerializerMethodField(
|
||||||
until = serializers.SerializerMethodField()
|
help_text=_(
|
||||||
|
'The timezone this schedule runs in. This field is extracted from the RRULE. If the timezone in the RRULE is a link to another timezone, the link will be reflected in this field.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
until = serializers.SerializerMethodField(
|
||||||
|
help_text=_('The date this schedule will end. This field is computed from the RRULE. If the schedule does not end an emptry string will be returned'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Schedule
|
model = Schedule
|
||||||
@@ -4738,13 +4740,6 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
|
|||||||
raise serializers.ValidationError(_('Inventory Source must be a cloud resource.'))
|
raise serializers.ValidationError(_('Inventory Source must be a cloud resource.'))
|
||||||
elif type(value) == Project and value.scm_type == '':
|
elif type(value) == Project and value.scm_type == '':
|
||||||
raise serializers.ValidationError(_('Manual Project cannot have a schedule set.'))
|
raise serializers.ValidationError(_('Manual Project cannot have a schedule set.'))
|
||||||
elif type(value) == InventorySource and value.source == 'scm' and value.update_on_project_update:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
_(
|
|
||||||
'Inventory sources with `update_on_project_update` cannot be scheduled. '
|
|
||||||
'Schedule its source project `{}` instead.'.format(value.source_project.name)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ from awx.api.metadata import RoleMetadata
|
|||||||
from awx.main.constants import ACTIVE_STATES, SURVEY_TYPE_MAPPING
|
from awx.main.constants import ACTIVE_STATES, SURVEY_TYPE_MAPPING
|
||||||
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
||||||
from awx.api.views.mixin import (
|
from awx.api.views.mixin import (
|
||||||
ControlledByScmMixin,
|
|
||||||
InstanceGroupMembershipMixin,
|
InstanceGroupMembershipMixin,
|
||||||
OrganizationCountsMixin,
|
OrganizationCountsMixin,
|
||||||
RelatedJobsPreventDeleteMixin,
|
RelatedJobsPreventDeleteMixin,
|
||||||
@@ -578,8 +577,7 @@ class ScheduleZoneInfo(APIView):
|
|||||||
swagger_topic = 'System Configuration'
|
swagger_topic = 'System Configuration'
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
zones = [{'name': zone} for zone in models.Schedule.get_zoneinfo()]
|
return Response({'zones': models.Schedule.get_zoneinfo(), 'links': models.Schedule.get_zoneinfo_links()})
|
||||||
return Response(zones)
|
|
||||||
|
|
||||||
|
|
||||||
class LaunchConfigCredentialsBase(SubListAttachDetachAPIView):
|
class LaunchConfigCredentialsBase(SubListAttachDetachAPIView):
|
||||||
@@ -1676,7 +1674,7 @@ class HostList(HostRelatedSearchMixin, ListCreateAPIView):
|
|||||||
return Response(dict(error=_(str(e))), status=status.HTTP_400_BAD_REQUEST)
|
return Response(dict(error=_(str(e))), status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
class HostDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
class HostDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
always_allow_superuser = False
|
always_allow_superuser = False
|
||||||
model = models.Host
|
model = models.Host
|
||||||
@@ -1710,7 +1708,7 @@ class InventoryHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIVie
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class HostGroupsList(ControlledByScmMixin, SubListCreateAttachDetachAPIView):
|
class HostGroupsList(SubListCreateAttachDetachAPIView):
|
||||||
'''the list of groups a host is directly a member of'''
|
'''the list of groups a host is directly a member of'''
|
||||||
|
|
||||||
model = models.Group
|
model = models.Group
|
||||||
@@ -1826,7 +1824,7 @@ class EnforceParentRelationshipMixin(object):
|
|||||||
return super(EnforceParentRelationshipMixin, self).create(request, *args, **kwargs)
|
return super(EnforceParentRelationshipMixin, self).create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class GroupChildrenList(ControlledByScmMixin, EnforceParentRelationshipMixin, SubListCreateAttachDetachAPIView):
|
class GroupChildrenList(EnforceParentRelationshipMixin, SubListCreateAttachDetachAPIView):
|
||||||
|
|
||||||
model = models.Group
|
model = models.Group
|
||||||
serializer_class = serializers.GroupSerializer
|
serializer_class = serializers.GroupSerializer
|
||||||
@@ -1872,7 +1870,7 @@ class GroupPotentialChildrenList(SubListAPIView):
|
|||||||
return qs.exclude(pk__in=except_pks)
|
return qs.exclude(pk__in=except_pks)
|
||||||
|
|
||||||
|
|
||||||
class GroupHostsList(HostRelatedSearchMixin, ControlledByScmMixin, SubListCreateAttachDetachAPIView):
|
class GroupHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIView):
|
||||||
'''the list of hosts directly below a group'''
|
'''the list of hosts directly below a group'''
|
||||||
|
|
||||||
model = models.Host
|
model = models.Host
|
||||||
@@ -1936,7 +1934,7 @@ class GroupActivityStreamList(SubListAPIView):
|
|||||||
return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all()))
|
return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all()))
|
||||||
|
|
||||||
|
|
||||||
class GroupDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
class GroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = models.Group
|
model = models.Group
|
||||||
serializer_class = serializers.GroupSerializer
|
serializer_class = serializers.GroupSerializer
|
||||||
@@ -3850,7 +3848,7 @@ class JobJobEventsChildrenSummary(APIView):
|
|||||||
meta_events = ('debug', 'verbose', 'warning', 'error', 'system_warning', 'deprecated')
|
meta_events = ('debug', 'verbose', 'warning', 'error', 'system_warning', 'deprecated')
|
||||||
|
|
||||||
def get(self, request, **kwargs):
|
def get(self, request, **kwargs):
|
||||||
resp = dict(children_summary={}, meta_event_nested_uuid={}, event_processing_finished=False)
|
resp = dict(children_summary={}, meta_event_nested_uuid={}, event_processing_finished=False, is_tree=True)
|
||||||
job = get_object_or_404(models.Job, pk=kwargs['pk'])
|
job = get_object_or_404(models.Job, pk=kwargs['pk'])
|
||||||
if not job.event_processing_finished:
|
if not job.event_processing_finished:
|
||||||
return Response(resp)
|
return Response(resp)
|
||||||
@@ -3870,13 +3868,41 @@ class JobJobEventsChildrenSummary(APIView):
|
|||||||
# key is counter of meta events (i.e. verbose), value is uuid of the assigned parent
|
# key is counter of meta events (i.e. verbose), value is uuid of the assigned parent
|
||||||
map_meta_counter_nested_uuid = {}
|
map_meta_counter_nested_uuid = {}
|
||||||
|
|
||||||
|
# collapsable tree view in the UI only makes sense for tree-like
|
||||||
|
# hierarchy. If ansible is ran with a strategy like free or host_pinned, then
|
||||||
|
# events can be out of sequential order, and no longer follow a tree structure
|
||||||
|
# E1
|
||||||
|
# E2
|
||||||
|
# E3
|
||||||
|
# E4 <- parent is E3
|
||||||
|
# E5 <- parent is E1
|
||||||
|
# in the above, there is no clear way to collapse E1, because E5 comes after
|
||||||
|
# E3, which occurs after E1. Thus the tree view should be disabled.
|
||||||
|
|
||||||
|
# mark the last seen uuid at a given level (0-3)
|
||||||
|
# if a parent uuid is not in this list, then we know the events are not tree-like
|
||||||
|
# and return a response with is_tree: False
|
||||||
|
level_current_uuid = [None, None, None, None]
|
||||||
|
|
||||||
prev_non_meta_event = events[0]
|
prev_non_meta_event = events[0]
|
||||||
for i, e in enumerate(events):
|
for i, e in enumerate(events):
|
||||||
if not e['event'] in JobJobEventsChildrenSummary.meta_events:
|
if not e['event'] in JobJobEventsChildrenSummary.meta_events:
|
||||||
prev_non_meta_event = e
|
prev_non_meta_event = e
|
||||||
if not e['uuid']:
|
if not e['uuid']:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not e['event'] in JobJobEventsChildrenSummary.meta_events:
|
||||||
|
level = models.JobEvent.LEVEL_FOR_EVENT[e['event']]
|
||||||
|
level_current_uuid[level] = e['uuid']
|
||||||
|
# if setting level 1, for example, set levels 2 and 3 back to None
|
||||||
|
for u in range(level + 1, len(level_current_uuid)):
|
||||||
|
level_current_uuid[u] = None
|
||||||
|
|
||||||
puuid = e['parent_uuid']
|
puuid = e['parent_uuid']
|
||||||
|
if puuid and puuid not in level_current_uuid:
|
||||||
|
# improper tree detected, so bail out early
|
||||||
|
resp['is_tree'] = False
|
||||||
|
return Response(resp)
|
||||||
|
|
||||||
# if event is verbose (or debug, etc), we need to "assign" it a
|
# if event is verbose (or debug, etc), we need to "assign" it a
|
||||||
# parent. This code looks at the event level of the previous
|
# parent. This code looks at the event level of the previous
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from awx.api.serializers import (
|
|||||||
JobTemplateSerializer,
|
JobTemplateSerializer,
|
||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
)
|
)
|
||||||
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, ControlledByScmMixin
|
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin
|
||||||
|
|
||||||
from awx.api.pagination import UnifiedJobEventPagination
|
from awx.api.pagination import UnifiedJobEventPagination
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ class InventoryList(ListCreateAPIView):
|
|||||||
serializer_class = InventorySerializer
|
serializer_class = InventorySerializer
|
||||||
|
|
||||||
|
|
||||||
class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = Inventory
|
model = Inventory
|
||||||
serializer_class = InventorySerializer
|
serializer_class = InventorySerializer
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ from django.shortcuts import get_object_or_404
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from rest_framework.permissions import SAFE_METHODS
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from awx.main.constants import ACTIVE_STATES
|
from awx.main.constants import ACTIVE_STATES
|
||||||
from awx.main.utils import get_object_or_400, parse_yaml_or_json
|
from awx.main.utils import get_object_or_400
|
||||||
from awx.main.models.ha import Instance, InstanceGroup
|
from awx.main.models.ha import Instance, InstanceGroup
|
||||||
from awx.main.models.organization import Team
|
from awx.main.models.organization import Team
|
||||||
from awx.main.models.projects import Project
|
from awx.main.models.projects import Project
|
||||||
@@ -186,35 +185,6 @@ class OrganizationCountsMixin(object):
|
|||||||
return full_context
|
return full_context
|
||||||
|
|
||||||
|
|
||||||
class ControlledByScmMixin(object):
|
|
||||||
"""
|
|
||||||
Special method to reset SCM inventory commit hash
|
|
||||||
if anything that it manages changes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _reset_inv_src_rev(self, obj):
|
|
||||||
if self.request.method in SAFE_METHODS or not obj:
|
|
||||||
return
|
|
||||||
project_following_sources = obj.inventory_sources.filter(update_on_project_update=True, source='scm')
|
|
||||||
if project_following_sources:
|
|
||||||
# Allow inventory changes unrelated to variables
|
|
||||||
if self.model == Inventory and (
|
|
||||||
not self.request or not self.request.data or parse_yaml_or_json(self.request.data.get('variables', '')) == parse_yaml_or_json(obj.variables)
|
|
||||||
):
|
|
||||||
return
|
|
||||||
project_following_sources.update(scm_last_revision='')
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
obj = super(ControlledByScmMixin, self).get_object()
|
|
||||||
self._reset_inv_src_rev(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def get_parent_object(self):
|
|
||||||
obj = super(ControlledByScmMixin, self).get_parent_object()
|
|
||||||
self._reset_inv_src_rev(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
class NoTruncateMixin(object):
|
class NoTruncateMixin(object):
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class GitlabWebhookReceiver(WebhookReceiverBase):
|
|||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
def get_event_status_api(self):
|
def get_event_status_api(self):
|
||||||
if self.get_event_type() != 'Merge Request Hook':
|
if self.get_event_type() not in self.ref_keys.keys():
|
||||||
return
|
return
|
||||||
project = self.request.data.get('project', {})
|
project = self.request.data.get('project', {})
|
||||||
repo_url = project.get('web_url')
|
repo_url = project.get('web_url')
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ from django.contrib.sessions.models import Session
|
|||||||
from django.utils.timezone import now, timedelta
|
from django.utils.timezone import now, timedelta
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from psycopg2.errors import UntranslatableCharacter
|
|
||||||
|
|
||||||
from awx.conf.license import get_license
|
from awx.conf.license import get_license
|
||||||
from awx.main.utils import get_awx_version, camelcase_to_underscore, datetime_hook
|
from awx.main.utils import get_awx_version, camelcase_to_underscore, datetime_hook
|
||||||
from awx.main import models
|
from awx.main import models
|
||||||
@@ -131,7 +129,7 @@ def config(since, **kwargs):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@register('counts', '1.1', description=_('Counts of objects such as organizations, inventories, and projects'))
|
@register('counts', '1.2', description=_('Counts of objects such as organizations, inventories, and projects'))
|
||||||
def counts(since, **kwargs):
|
def counts(since, **kwargs):
|
||||||
counts = {}
|
counts = {}
|
||||||
for cls in (
|
for cls in (
|
||||||
@@ -174,6 +172,13 @@ def counts(since, **kwargs):
|
|||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
counts['pending_jobs'] = models.UnifiedJob.objects.exclude(launch_type='sync').filter(status__in=('pending',)).count()
|
counts['pending_jobs'] = models.UnifiedJob.objects.exclude(launch_type='sync').filter(status__in=('pending',)).count()
|
||||||
|
if connection.vendor == 'postgresql':
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(f"select count(*) from pg_stat_activity where datname=\'{connection.settings_dict['NAME']}\'")
|
||||||
|
counts['database_connections'] = cursor.fetchone()[0]
|
||||||
|
else:
|
||||||
|
# We should be using postgresql, but if we do that change that ever we should change the below value
|
||||||
|
counts['database_connections'] = 1
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
@@ -378,10 +383,7 @@ def _events_table(since, full_path, until, tbl, where_column, project_job_create
|
|||||||
WHERE ({tbl}.{where_column} > '{since.isoformat()}' AND {tbl}.{where_column} <= '{until.isoformat()}')) TO STDOUT WITH CSV HEADER'''
|
WHERE ({tbl}.{where_column} > '{since.isoformat()}' AND {tbl}.{where_column} <= '{until.isoformat()}')) TO STDOUT WITH CSV HEADER'''
|
||||||
return query
|
return query
|
||||||
|
|
||||||
try:
|
return _copy_table(table='events', query=query(fr"replace({tbl}.event_data, '\u', '\u005cu')::jsonb"), path=full_path)
|
||||||
return _copy_table(table='events', query=query(f"{tbl}.event_data::jsonb"), path=full_path)
|
|
||||||
except UntranslatableCharacter:
|
|
||||||
return _copy_table(table='events', query=query(f"replace({tbl}.event_data::text, '\\u0000', '')::jsonb"), path=full_path)
|
|
||||||
|
|
||||||
|
|
||||||
@register('events_table', '1.5', format='csv', description=_('Automation task records'), expensive=four_hour_slicing)
|
@register('events_table', '1.5', format='csv', description=_('Automation task records'), expensive=four_hour_slicing)
|
||||||
@@ -394,7 +396,7 @@ def events_table_partitioned_modified(since, full_path, until, **kwargs):
|
|||||||
return _events_table(since, full_path, until, 'main_jobevent', 'modified', project_job_created=True, **kwargs)
|
return _events_table(since, full_path, until, 'main_jobevent', 'modified', project_job_created=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@register('unified_jobs_table', '1.3', format='csv', description=_('Data on jobs run'), expensive=four_hour_slicing)
|
@register('unified_jobs_table', '1.4', format='csv', description=_('Data on jobs run'), expensive=four_hour_slicing)
|
||||||
def unified_jobs_table(since, full_path, until, **kwargs):
|
def unified_jobs_table(since, full_path, until, **kwargs):
|
||||||
unified_job_query = '''COPY (SELECT main_unifiedjob.id,
|
unified_job_query = '''COPY (SELECT main_unifiedjob.id,
|
||||||
main_unifiedjob.polymorphic_ctype_id,
|
main_unifiedjob.polymorphic_ctype_id,
|
||||||
@@ -420,7 +422,8 @@ def unified_jobs_table(since, full_path, until, **kwargs):
|
|||||||
main_unifiedjob.job_explanation,
|
main_unifiedjob.job_explanation,
|
||||||
main_unifiedjob.instance_group_id,
|
main_unifiedjob.instance_group_id,
|
||||||
main_unifiedjob.installed_collections,
|
main_unifiedjob.installed_collections,
|
||||||
main_unifiedjob.ansible_version
|
main_unifiedjob.ansible_version,
|
||||||
|
main_job.forks
|
||||||
FROM main_unifiedjob
|
FROM main_unifiedjob
|
||||||
JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id
|
JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id
|
||||||
LEFT JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id
|
LEFT JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ def metrics():
|
|||||||
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license', registry=REGISTRY)
|
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license', registry=REGISTRY)
|
||||||
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license', registry=REGISTRY)
|
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license', registry=REGISTRY)
|
||||||
|
|
||||||
|
DATABASE_CONNECTIONS = Gauge('awx_database_connections_total', 'Number of connections to database', registry=REGISTRY)
|
||||||
|
|
||||||
license_info = get_license()
|
license_info = get_license()
|
||||||
SYSTEM_INFO.info(
|
SYSTEM_INFO.info(
|
||||||
{
|
{
|
||||||
@@ -163,6 +165,8 @@ def metrics():
|
|||||||
USER_SESSIONS.labels(type='user').set(current_counts['active_user_sessions'])
|
USER_SESSIONS.labels(type='user').set(current_counts['active_user_sessions'])
|
||||||
USER_SESSIONS.labels(type='anonymous').set(current_counts['active_anonymous_sessions'])
|
USER_SESSIONS.labels(type='anonymous').set(current_counts['active_anonymous_sessions'])
|
||||||
|
|
||||||
|
DATABASE_CONNECTIONS.set(current_counts['database_connections'])
|
||||||
|
|
||||||
all_job_data = job_counts(None)
|
all_job_data = job_counts(None)
|
||||||
statuses = all_job_data.get('status', {})
|
statuses = all_job_data.get('status', {})
|
||||||
for status, value in statuses.items():
|
for status, value in statuses.items():
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.apps import apps
|
|||||||
from awx.main.consumers import emit_channel_notification
|
from awx.main.consumers import emit_channel_notification
|
||||||
|
|
||||||
root_key = 'awx_metrics'
|
root_key = 'awx_metrics'
|
||||||
logger = logging.getLogger('awx.main.wsbroadcast')
|
logger = logging.getLogger('awx.main.analytics')
|
||||||
|
|
||||||
|
|
||||||
class BaseM:
|
class BaseM:
|
||||||
@@ -16,16 +16,22 @@ class BaseM:
|
|||||||
self.field = field
|
self.field = field
|
||||||
self.help_text = help_text
|
self.help_text = help_text
|
||||||
self.current_value = 0
|
self.current_value = 0
|
||||||
|
self.metric_has_changed = False
|
||||||
|
|
||||||
def clear_value(self, conn):
|
def reset_value(self, conn):
|
||||||
conn.hset(root_key, self.field, 0)
|
conn.hset(root_key, self.field, 0)
|
||||||
self.current_value = 0
|
self.current_value = 0
|
||||||
|
|
||||||
def inc(self, value):
|
def inc(self, value):
|
||||||
self.current_value += value
|
self.current_value += value
|
||||||
|
self.metric_has_changed = True
|
||||||
|
|
||||||
def set(self, value):
|
def set(self, value):
|
||||||
self.current_value = value
|
self.current_value = value
|
||||||
|
self.metric_has_changed = True
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.current_value
|
||||||
|
|
||||||
def decode(self, conn):
|
def decode(self, conn):
|
||||||
value = conn.hget(root_key, self.field)
|
value = conn.hget(root_key, self.field)
|
||||||
@@ -34,7 +40,9 @@ class BaseM:
|
|||||||
def to_prometheus(self, instance_data):
|
def to_prometheus(self, instance_data):
|
||||||
output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} gauge\n"
|
output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} gauge\n"
|
||||||
for instance in instance_data:
|
for instance in instance_data:
|
||||||
output_text += f'{self.field}{{node="{instance}"}} {instance_data[instance][self.field]}\n'
|
if self.field in instance_data[instance]:
|
||||||
|
# on upgrade, if there are stale instances, we can end up with issues where new metrics are not present
|
||||||
|
output_text += f'{self.field}{{node="{instance}"}} {instance_data[instance][self.field]}\n'
|
||||||
return output_text
|
return output_text
|
||||||
|
|
||||||
|
|
||||||
@@ -46,8 +54,10 @@ class FloatM(BaseM):
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def store_value(self, conn):
|
def store_value(self, conn):
|
||||||
conn.hincrbyfloat(root_key, self.field, self.current_value)
|
if self.metric_has_changed:
|
||||||
self.current_value = 0
|
conn.hincrbyfloat(root_key, self.field, self.current_value)
|
||||||
|
self.current_value = 0
|
||||||
|
self.metric_has_changed = False
|
||||||
|
|
||||||
|
|
||||||
class IntM(BaseM):
|
class IntM(BaseM):
|
||||||
@@ -58,8 +68,10 @@ class IntM(BaseM):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def store_value(self, conn):
|
def store_value(self, conn):
|
||||||
conn.hincrby(root_key, self.field, self.current_value)
|
if self.metric_has_changed:
|
||||||
self.current_value = 0
|
conn.hincrby(root_key, self.field, self.current_value)
|
||||||
|
self.current_value = 0
|
||||||
|
self.metric_has_changed = False
|
||||||
|
|
||||||
|
|
||||||
class SetIntM(BaseM):
|
class SetIntM(BaseM):
|
||||||
@@ -70,10 +82,9 @@ class SetIntM(BaseM):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def store_value(self, conn):
|
def store_value(self, conn):
|
||||||
# do not set value if it has not changed since last time this was called
|
if self.metric_has_changed:
|
||||||
if self.current_value is not None:
|
|
||||||
conn.hset(root_key, self.field, self.current_value)
|
conn.hset(root_key, self.field, self.current_value)
|
||||||
self.current_value = None
|
self.metric_has_changed = False
|
||||||
|
|
||||||
|
|
||||||
class SetFloatM(SetIntM):
|
class SetFloatM(SetIntM):
|
||||||
@@ -94,13 +105,13 @@ class HistogramM(BaseM):
|
|||||||
self.sum = IntM(field + '_sum', '')
|
self.sum = IntM(field + '_sum', '')
|
||||||
super(HistogramM, self).__init__(field, help_text)
|
super(HistogramM, self).__init__(field, help_text)
|
||||||
|
|
||||||
def clear_value(self, conn):
|
def reset_value(self, conn):
|
||||||
conn.hset(root_key, self.field, 0)
|
conn.hset(root_key, self.field, 0)
|
||||||
self.inf.clear_value(conn)
|
self.inf.reset_value(conn)
|
||||||
self.sum.clear_value(conn)
|
self.sum.reset_value(conn)
|
||||||
for b in self.buckets_to_keys.values():
|
for b in self.buckets_to_keys.values():
|
||||||
b.clear_value(conn)
|
b.reset_value(conn)
|
||||||
super(HistogramM, self).clear_value(conn)
|
super(HistogramM, self).reset_value(conn)
|
||||||
|
|
||||||
def observe(self, value):
|
def observe(self, value):
|
||||||
for b in self.buckets:
|
for b in self.buckets:
|
||||||
@@ -136,7 +147,7 @@ class HistogramM(BaseM):
|
|||||||
|
|
||||||
|
|
||||||
class Metrics:
|
class Metrics:
|
||||||
def __init__(self, auto_pipe_execute=True, instance_name=None):
|
def __init__(self, auto_pipe_execute=False, instance_name=None):
|
||||||
self.pipe = redis.Redis.from_url(settings.BROKER_URL).pipeline()
|
self.pipe = redis.Redis.from_url(settings.BROKER_URL).pipeline()
|
||||||
self.conn = redis.Redis.from_url(settings.BROKER_URL)
|
self.conn = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
self.last_pipe_execute = time.time()
|
self.last_pipe_execute = time.time()
|
||||||
@@ -152,6 +163,8 @@ class Metrics:
|
|||||||
Instance = apps.get_model('main', 'Instance')
|
Instance = apps.get_model('main', 'Instance')
|
||||||
if instance_name:
|
if instance_name:
|
||||||
self.instance_name = instance_name
|
self.instance_name = instance_name
|
||||||
|
elif settings.IS_TESTING():
|
||||||
|
self.instance_name = "awx_testing"
|
||||||
else:
|
else:
|
||||||
self.instance_name = Instance.objects.me().hostname
|
self.instance_name = Instance.objects.me().hostname
|
||||||
|
|
||||||
@@ -161,15 +174,29 @@ class Metrics:
|
|||||||
IntM('callback_receiver_events_popped_redis', 'Number of events popped from redis'),
|
IntM('callback_receiver_events_popped_redis', 'Number of events popped from redis'),
|
||||||
IntM('callback_receiver_events_in_memory', 'Current number of events in memory (in transfer from redis to db)'),
|
IntM('callback_receiver_events_in_memory', 'Current number of events in memory (in transfer from redis to db)'),
|
||||||
IntM('callback_receiver_batch_events_errors', 'Number of times batch insertion failed'),
|
IntM('callback_receiver_batch_events_errors', 'Number of times batch insertion failed'),
|
||||||
FloatM('callback_receiver_events_insert_db_seconds', 'Time spent saving events to database'),
|
FloatM('callback_receiver_events_insert_db_seconds', 'Total time spent saving events to database'),
|
||||||
IntM('callback_receiver_events_insert_db', 'Number of events batch inserted into database'),
|
IntM('callback_receiver_events_insert_db', 'Number of events batch inserted into database'),
|
||||||
IntM('callback_receiver_events_broadcast', 'Number of events broadcast to other control plane nodes'),
|
IntM('callback_receiver_events_broadcast', 'Number of events broadcast to other control plane nodes'),
|
||||||
HistogramM(
|
HistogramM(
|
||||||
'callback_receiver_batch_events_insert_db', 'Number of events batch inserted into database', settings.SUBSYSTEM_METRICS_BATCH_INSERT_BUCKETS
|
'callback_receiver_batch_events_insert_db', 'Number of events batch inserted into database', settings.SUBSYSTEM_METRICS_BATCH_INSERT_BUCKETS
|
||||||
),
|
),
|
||||||
|
SetFloatM('callback_receiver_event_processing_avg_seconds', 'Average processing time per event per callback receiver batch'),
|
||||||
FloatM('subsystem_metrics_pipe_execute_seconds', 'Time spent saving metrics to redis'),
|
FloatM('subsystem_metrics_pipe_execute_seconds', 'Time spent saving metrics to redis'),
|
||||||
IntM('subsystem_metrics_pipe_execute_calls', 'Number of calls to pipe_execute'),
|
IntM('subsystem_metrics_pipe_execute_calls', 'Number of calls to pipe_execute'),
|
||||||
FloatM('subsystem_metrics_send_metrics_seconds', 'Time spent sending metrics to other nodes'),
|
FloatM('subsystem_metrics_send_metrics_seconds', 'Time spent sending metrics to other nodes'),
|
||||||
|
SetFloatM('task_manager_get_tasks_seconds', 'Time spent in loading all tasks from db'),
|
||||||
|
SetFloatM('task_manager_start_task_seconds', 'Time spent starting task'),
|
||||||
|
SetFloatM('task_manager_process_running_tasks_seconds', 'Time spent processing running tasks'),
|
||||||
|
SetFloatM('task_manager_process_pending_tasks_seconds', 'Time spent processing pending tasks'),
|
||||||
|
SetFloatM('task_manager_generate_dependencies_seconds', 'Time spent generating dependencies for pending tasks'),
|
||||||
|
SetFloatM('task_manager_spawn_workflow_graph_jobs_seconds', 'Time spent spawning workflow jobs'),
|
||||||
|
SetFloatM('task_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
||||||
|
IntM('task_manager_schedule_calls', 'Number of calls to task manager schedule'),
|
||||||
|
SetFloatM('task_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
||||||
|
SetIntM('task_manager_tasks_started', 'Number of tasks started'),
|
||||||
|
SetIntM('task_manager_running_processed', 'Number of running tasks processed'),
|
||||||
|
SetIntM('task_manager_pending_processed', 'Number of pending tasks processed'),
|
||||||
|
SetIntM('task_manager_tasks_blocked', 'Number of tasks blocked from running'),
|
||||||
]
|
]
|
||||||
# turn metric list into dictionary with the metric name as a key
|
# turn metric list into dictionary with the metric name as a key
|
||||||
self.METRICS = {}
|
self.METRICS = {}
|
||||||
@@ -179,29 +206,39 @@ class Metrics:
|
|||||||
# track last time metrics were sent to other nodes
|
# track last time metrics were sent to other nodes
|
||||||
self.previous_send_metrics = SetFloatM('send_metrics_time', 'Timestamp of previous send_metrics call')
|
self.previous_send_metrics = SetFloatM('send_metrics_time', 'Timestamp of previous send_metrics call')
|
||||||
|
|
||||||
def clear_values(self):
|
def reset_values(self):
|
||||||
|
# intended to be called once on app startup to reset all metric
|
||||||
|
# values to 0
|
||||||
for m in self.METRICS.values():
|
for m in self.METRICS.values():
|
||||||
m.clear_value(self.conn)
|
m.reset_value(self.conn)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
self.conn.delete(root_key + "_lock")
|
self.conn.delete(root_key + "_lock")
|
||||||
|
for m in self.conn.scan_iter(root_key + '_instance_*'):
|
||||||
|
self.conn.delete(m)
|
||||||
|
|
||||||
def inc(self, field, value):
|
def inc(self, field, value):
|
||||||
if value != 0:
|
if value != 0:
|
||||||
self.METRICS[field].inc(value)
|
self.METRICS[field].inc(value)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
if self.auto_pipe_execute is True:
|
||||||
self.pipe_execute()
|
self.pipe_execute()
|
||||||
|
|
||||||
def set(self, field, value):
|
def set(self, field, value):
|
||||||
self.METRICS[field].set(value)
|
self.METRICS[field].set(value)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
if self.auto_pipe_execute is True:
|
||||||
self.pipe_execute()
|
self.pipe_execute()
|
||||||
|
|
||||||
|
def get(self, field):
|
||||||
|
return self.METRICS[field].get()
|
||||||
|
|
||||||
|
def decode(self, field):
|
||||||
|
return self.METRICS[field].decode(self.conn)
|
||||||
|
|
||||||
def observe(self, field, value):
|
def observe(self, field, value):
|
||||||
self.METRICS[field].observe(value)
|
self.METRICS[field].observe(value)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
if self.auto_pipe_execute is True:
|
||||||
self.pipe_execute()
|
self.pipe_execute()
|
||||||
|
|
||||||
def serialize_local_metrics(self):
|
def serialize_local_metrics(self):
|
||||||
@@ -249,8 +286,8 @@ class Metrics:
|
|||||||
|
|
||||||
def send_metrics(self):
|
def send_metrics(self):
|
||||||
# more than one thread could be calling this at the same time, so should
|
# more than one thread could be calling this at the same time, so should
|
||||||
# get acquire redis lock before sending metrics
|
# acquire redis lock before sending metrics
|
||||||
lock = self.conn.lock(root_key + '_lock', thread_local=False)
|
lock = self.conn.lock(root_key + '_lock')
|
||||||
if not lock.acquire(blocking=False):
|
if not lock.acquire(blocking=False):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -10,6 +10,27 @@ from awx.main.models import Instance, UnifiedJob, WorkflowJob
|
|||||||
logger = logging.getLogger('awx.main.dispatch')
|
logger = logging.getLogger('awx.main.dispatch')
|
||||||
|
|
||||||
|
|
||||||
|
def startup_reaping():
|
||||||
|
"""
|
||||||
|
If this particular instance is starting, then we know that any running jobs are invalid
|
||||||
|
so we will reap those jobs as a special action here
|
||||||
|
"""
|
||||||
|
me = Instance.objects.me()
|
||||||
|
jobs = UnifiedJob.objects.filter(status='running', controller_node=me.hostname)
|
||||||
|
job_ids = []
|
||||||
|
for j in jobs:
|
||||||
|
job_ids.append(j.id)
|
||||||
|
j.status = 'failed'
|
||||||
|
j.start_args = ''
|
||||||
|
j.job_explanation += 'Task was marked as running at system start up. The system must have not shut down properly, so it has been marked as failed.'
|
||||||
|
j.save(update_fields=['status', 'start_args', 'job_explanation'])
|
||||||
|
if hasattr(j, 'send_notification_templates'):
|
||||||
|
j.send_notification_templates('failed')
|
||||||
|
j.websocket_emit_status('failed')
|
||||||
|
if job_ids:
|
||||||
|
logger.error(f'Unified jobs {job_ids} were reaped on dispatch startup')
|
||||||
|
|
||||||
|
|
||||||
def reap_job(j, status):
|
def reap_job(j, status):
|
||||||
if UnifiedJob.objects.get(id=j.id).status not in ('running', 'waiting'):
|
if UnifiedJob.objects.get(id=j.id).status not in ('running', 'waiting'):
|
||||||
# just in case, don't reap jobs that aren't running
|
# just in case, don't reap jobs that aren't running
|
||||||
|
|||||||
@@ -169,8 +169,9 @@ class AWXConsumerPG(AWXConsumerBase):
|
|||||||
logger.exception(f"Error consuming new events from postgres, will retry for {self.pg_max_wait} s")
|
logger.exception(f"Error consuming new events from postgres, will retry for {self.pg_max_wait} s")
|
||||||
self.pg_down_time = time.time()
|
self.pg_down_time = time.time()
|
||||||
self.pg_is_down = True
|
self.pg_is_down = True
|
||||||
if time.time() - self.pg_down_time > self.pg_max_wait:
|
current_downtime = time.time() - self.pg_down_time
|
||||||
logger.warning(f"Postgres event consumer has not recovered in {self.pg_max_wait} s, exiting")
|
if current_downtime > self.pg_max_wait:
|
||||||
|
logger.exception(f"Postgres event consumer has not recovered in {current_downtime} s, exiting")
|
||||||
raise
|
raise
|
||||||
# Wait for a second before next attempt, but still listen for any shutdown signals
|
# Wait for a second before next attempt, but still listen for any shutdown signals
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
@@ -179,6 +180,10 @@ class AWXConsumerPG(AWXConsumerBase):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
for conn in db.connections.all():
|
for conn in db.connections.all():
|
||||||
conn.close_if_unusable_or_obsolete()
|
conn.close_if_unusable_or_obsolete()
|
||||||
|
except Exception:
|
||||||
|
# Log unanticipated exception in addition to writing to stderr to get timestamps and other metadata
|
||||||
|
logger.exception('Encountered unhandled error in dispatcher main loop')
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class BaseWorker(object):
|
class BaseWorker(object):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import datetime
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -151,12 +152,17 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
metrics_singular_events_saved = 0
|
metrics_singular_events_saved = 0
|
||||||
metrics_events_batch_save_errors = 0
|
metrics_events_batch_save_errors = 0
|
||||||
metrics_events_broadcast = 0
|
metrics_events_broadcast = 0
|
||||||
|
metrics_events_missing_created = 0
|
||||||
|
metrics_total_job_event_processing_seconds = datetime.timedelta(seconds=0)
|
||||||
for cls, events in self.buff.items():
|
for cls, events in self.buff.items():
|
||||||
logger.debug(f'{cls.__name__}.objects.bulk_create({len(events)})')
|
logger.debug(f'{cls.__name__}.objects.bulk_create({len(events)})')
|
||||||
for e in events:
|
for e in events:
|
||||||
|
e.modified = now # this can be set before created because now is set above on line 149
|
||||||
if not e.created:
|
if not e.created:
|
||||||
e.created = now
|
e.created = now
|
||||||
e.modified = now
|
metrics_events_missing_created += 1
|
||||||
|
else: # only calculate the seconds if the created time already has been set
|
||||||
|
metrics_total_job_event_processing_seconds += e.modified - e.created
|
||||||
metrics_duration_to_save = time.perf_counter()
|
metrics_duration_to_save = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
cls.objects.bulk_create(events)
|
cls.objects.bulk_create(events)
|
||||||
@@ -189,6 +195,11 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
self.subsystem_metrics.observe('callback_receiver_batch_events_insert_db', metrics_bulk_events_saved)
|
self.subsystem_metrics.observe('callback_receiver_batch_events_insert_db', metrics_bulk_events_saved)
|
||||||
self.subsystem_metrics.inc('callback_receiver_events_in_memory', -(metrics_bulk_events_saved + metrics_singular_events_saved))
|
self.subsystem_metrics.inc('callback_receiver_events_in_memory', -(metrics_bulk_events_saved + metrics_singular_events_saved))
|
||||||
self.subsystem_metrics.inc('callback_receiver_events_broadcast', metrics_events_broadcast)
|
self.subsystem_metrics.inc('callback_receiver_events_broadcast', metrics_events_broadcast)
|
||||||
|
self.subsystem_metrics.set(
|
||||||
|
'callback_receiver_event_processing_avg_seconds',
|
||||||
|
metrics_total_job_event_processing_seconds.total_seconds()
|
||||||
|
/ (metrics_bulk_events_saved + metrics_singular_events_saved - metrics_events_missing_created),
|
||||||
|
)
|
||||||
if self.subsystem_metrics.should_pipe_execute() is True:
|
if self.subsystem_metrics.should_pipe_execute() is True:
|
||||||
self.subsystem_metrics.pipe_execute()
|
self.subsystem_metrics.pipe_execute()
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ class Command(BaseCommand):
|
|||||||
name='Demo Project',
|
name='Demo Project',
|
||||||
scm_type='git',
|
scm_type='git',
|
||||||
scm_url='https://github.com/ansible/ansible-tower-samples',
|
scm_url='https://github.com/ansible/ansible-tower-samples',
|
||||||
scm_update_on_launch=True,
|
|
||||||
scm_update_cache_timeout=0,
|
scm_update_cache_timeout=0,
|
||||||
|
status='successful',
|
||||||
|
scm_revision='347e44fea036c94d5f60e544de006453ee5c71ad',
|
||||||
|
playbook_files=['hello_world.yml'],
|
||||||
)
|
)
|
||||||
|
|
||||||
p.organization = o
|
p.organization = o
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class Command(BaseCommand):
|
|||||||
# (like the node heartbeat)
|
# (like the node heartbeat)
|
||||||
periodic.run_continuously()
|
periodic.run_continuously()
|
||||||
|
|
||||||
reaper.reap()
|
reaper.startup_reaping()
|
||||||
consumer = None
|
consumer = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
23
awx/main/migrations/0163_convert_job_tags_to_textfield.py
Normal file
23
awx/main/migrations/0163_convert_job_tags_to_textfield.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-02 18:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0162_alter_unifiedjob_dependent_jobs'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='job',
|
||||||
|
name='job_tags',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='jobtemplate',
|
||||||
|
name='job_tags',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-21 21:29
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("awx")
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
InventorySource = apps.get_model('main', 'InventorySource')
|
||||||
|
sources = InventorySource.objects.filter(update_on_project_update=True)
|
||||||
|
for src in sources:
|
||||||
|
if src.update_on_launch == False:
|
||||||
|
src.update_on_launch = True
|
||||||
|
src.save(update_fields=['update_on_launch'])
|
||||||
|
logger.info(f"Setting update_on_launch to True for {src}")
|
||||||
|
proj = src.source_project
|
||||||
|
if proj and proj.scm_update_on_launch is False:
|
||||||
|
proj.scm_update_on_launch = True
|
||||||
|
proj.save(update_fields=['scm_update_on_launch'])
|
||||||
|
logger.warning(f"Setting scm_update_on_launch to True for {proj}")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0163_convert_job_tags_to_textfield'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='inventorysource',
|
||||||
|
name='scm_last_revision',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='inventorysource',
|
||||||
|
name='update_on_project_update',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -35,6 +35,7 @@ def gce(cred, env, private_data_dir):
|
|||||||
container_path = to_container_path(path, private_data_dir)
|
container_path = to_container_path(path, private_data_dir)
|
||||||
env['GCE_CREDENTIALS_FILE_PATH'] = container_path
|
env['GCE_CREDENTIALS_FILE_PATH'] = container_path
|
||||||
env['GCP_SERVICE_ACCOUNT_FILE'] = container_path
|
env['GCP_SERVICE_ACCOUNT_FILE'] = container_path
|
||||||
|
env['GOOGLE_APPLICATION_CREDENTIALS'] = container_path
|
||||||
|
|
||||||
# Handle env variables for new module types.
|
# Handle env variables for new module types.
|
||||||
# This includes gcp_compute inventory plugin and
|
# This includes gcp_compute inventory plugin and
|
||||||
|
|||||||
@@ -985,22 +985,11 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
scm_last_revision = models.CharField(
|
|
||||||
max_length=1024,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
editable=False,
|
|
||||||
)
|
|
||||||
update_on_project_update = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text=_(
|
|
||||||
'This field is deprecated and will be removed in a future release. '
|
|
||||||
'In future release, functionality will be migrated to source project update_on_launch.'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
update_on_launch = models.BooleanField(
|
update_on_launch = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
update_cache_timeout = models.PositiveIntegerField(
|
update_cache_timeout = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
)
|
)
|
||||||
@@ -1038,14 +1027,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
self.name = 'inventory source (%s)' % replace_text
|
self.name = 'inventory source (%s)' % replace_text
|
||||||
if 'name' not in update_fields:
|
if 'name' not in update_fields:
|
||||||
update_fields.append('name')
|
update_fields.append('name')
|
||||||
# Reset revision if SCM source has changed parameters
|
|
||||||
if self.source == 'scm' and not is_new_instance:
|
|
||||||
before_is = self.__class__.objects.get(pk=self.pk)
|
|
||||||
if before_is.source_path != self.source_path or before_is.source_project_id != self.source_project_id:
|
|
||||||
# Reset the scm_revision if file changed to force update
|
|
||||||
self.scm_last_revision = ''
|
|
||||||
if 'scm_last_revision' not in update_fields:
|
|
||||||
update_fields.append('scm_last_revision')
|
|
||||||
|
|
||||||
# Do the actual save.
|
# Do the actual save.
|
||||||
super(InventorySource, self).save(*args, **kwargs)
|
super(InventorySource, self).save(*args, **kwargs)
|
||||||
@@ -1054,10 +1035,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
if replace_text in self.name:
|
if replace_text in self.name:
|
||||||
self.name = self.name.replace(replace_text, str(self.pk))
|
self.name = self.name.replace(replace_text, str(self.pk))
|
||||||
super(InventorySource, self).save(update_fields=['name'])
|
super(InventorySource, self).save(update_fields=['name'])
|
||||||
if self.source == 'scm' and is_new_instance and self.update_on_project_update:
|
|
||||||
# Schedule a new Project update if one is not already queued
|
|
||||||
if self.source_project and not self.source_project.project_updates.filter(status__in=['new', 'pending', 'waiting']).exists():
|
|
||||||
self.update()
|
|
||||||
if not getattr(_inventory_updates, 'is_updating', False):
|
if not getattr(_inventory_updates, 'is_updating', False):
|
||||||
if self.inventory is not None:
|
if self.inventory is not None:
|
||||||
self.inventory.update_computed_fields()
|
self.inventory.update_computed_fields()
|
||||||
@@ -1147,25 +1124,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
)
|
)
|
||||||
return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates))
|
return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates))
|
||||||
|
|
||||||
def clean_update_on_project_update(self):
|
|
||||||
if (
|
|
||||||
self.update_on_project_update is True
|
|
||||||
and self.source == 'scm'
|
|
||||||
and InventorySource.objects.filter(Q(inventory=self.inventory, update_on_project_update=True, source='scm') & ~Q(id=self.id)).exists()
|
|
||||||
):
|
|
||||||
raise ValidationError(_("More than one SCM-based inventory source with update on project update per-inventory not allowed."))
|
|
||||||
return self.update_on_project_update
|
|
||||||
|
|
||||||
def clean_update_on_launch(self):
|
|
||||||
if self.update_on_project_update is True and self.source == 'scm' and self.update_on_launch is True:
|
|
||||||
raise ValidationError(
|
|
||||||
_(
|
|
||||||
"Cannot update SCM-based inventory source on launch if set to update on project update. "
|
|
||||||
"Instead, configure the corresponding source project to update on launch."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return self.update_on_launch
|
|
||||||
|
|
||||||
def clean_source_path(self):
|
def clean_source_path(self):
|
||||||
if self.source != 'scm' and self.source_path:
|
if self.source != 'scm' and self.source_path:
|
||||||
raise ValidationError(_("Cannot set source_path if not SCM type."))
|
raise ValidationError(_("Cannot set source_path if not SCM type."))
|
||||||
@@ -1301,13 +1259,6 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
|
|||||||
return self.global_instance_groups
|
return self.global_instance_groups
|
||||||
return selected_groups
|
return selected_groups
|
||||||
|
|
||||||
def cancel(self, job_explanation=None, is_chain=False):
|
|
||||||
res = super(InventoryUpdate, self).cancel(job_explanation=job_explanation, is_chain=is_chain)
|
|
||||||
if res:
|
|
||||||
if self.launch_type != 'scm' and self.source_project_update:
|
|
||||||
self.source_project_update.cancel(job_explanation=job_explanation)
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin):
|
class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -130,8 +130,7 @@ class JobOptions(BaseModel):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
job_tags = models.CharField(
|
job_tags = models.TextField(
|
||||||
max_length=1024,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
)
|
)
|
||||||
@@ -744,6 +743,12 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
return "$hidden due to Ansible no_log flag$"
|
return "$hidden due to Ansible no_log flag$"
|
||||||
return artifacts
|
return artifacts
|
||||||
|
|
||||||
|
def get_effective_artifacts(self, **kwargs):
|
||||||
|
"""Return unified job artifacts (from set_stats) to pass downstream in workflows"""
|
||||||
|
if isinstance(self.artifacts, dict):
|
||||||
|
return self.artifacts
|
||||||
|
return {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_container_group_task(self):
|
def is_container_group_task(self):
|
||||||
return bool(self.instance_group and self.instance_group.is_container_group)
|
return bool(self.instance_group and self.instance_group.is_container_group)
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ class JobNotificationMixin(object):
|
|||||||
'inventory': 'Stub Inventory',
|
'inventory': 'Stub Inventory',
|
||||||
'id': 42,
|
'id': 42,
|
||||||
'hosts': {},
|
'hosts': {},
|
||||||
|
'extra_vars': {},
|
||||||
'friendly_name': 'Job',
|
'friendly_name': 'Job',
|
||||||
'finished': False,
|
'finished': False,
|
||||||
'credential': 'Stub credential',
|
'credential': 'Stub credential',
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
# If update_fields has been specified, add our field names to it,
|
# If update_fields has been specified, add our field names to it,
|
||||||
# if it hasn't been specified, then we're just doing a normal save.
|
# if it hasn't been specified, then we're just doing a normal save.
|
||||||
update_fields = kwargs.get('update_fields', [])
|
update_fields = kwargs.get('update_fields', [])
|
||||||
skip_update = bool(kwargs.pop('skip_update', False))
|
self._skip_update = bool(kwargs.pop('skip_update', False))
|
||||||
# Create auto-generated local path if project uses SCM.
|
# Create auto-generated local path if project uses SCM.
|
||||||
if self.pk and self.scm_type and not self.local_path.startswith('_'):
|
if self.pk and self.scm_type and not self.local_path.startswith('_'):
|
||||||
slug_name = slugify(str(self.name)).replace(u'-', u'_')
|
slug_name = slugify(str(self.name)).replace(u'-', u'_')
|
||||||
@@ -372,14 +372,16 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
from awx.main.signals import disable_activity_stream
|
from awx.main.signals import disable_activity_stream
|
||||||
|
|
||||||
with disable_activity_stream():
|
with disable_activity_stream():
|
||||||
self.save(update_fields=update_fields)
|
self.save(update_fields=update_fields, skip_update=self._skip_update)
|
||||||
# If we just created a new project with SCM, start the initial update.
|
# If we just created a new project with SCM, start the initial update.
|
||||||
# also update if certain fields have changed
|
# also update if certain fields have changed
|
||||||
relevant_change = any(pre_save_vals.get(fd_name, None) != self._prior_values_store.get(fd_name, None) for fd_name in self.FIELDS_TRIGGER_UPDATE)
|
relevant_change = any(pre_save_vals.get(fd_name, None) != self._prior_values_store.get(fd_name, None) for fd_name in self.FIELDS_TRIGGER_UPDATE)
|
||||||
if (relevant_change or new_instance) and (not skip_update) and self.scm_type:
|
if (relevant_change or new_instance) and (not self._skip_update) and self.scm_type:
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _get_current_status(self):
|
def _get_current_status(self):
|
||||||
|
if getattr(self, '_skip_update', False):
|
||||||
|
return self.status
|
||||||
if self.scm_type:
|
if self.scm_type:
|
||||||
if self.current_job and self.current_job.status:
|
if self.current_job and self.current_job.status:
|
||||||
return self.current_job.status
|
return self.current_job.status
|
||||||
|
|||||||
@@ -85,9 +85,18 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
|
|||||||
next_run = models.DateTimeField(null=True, default=None, editable=False, help_text=_("The next time that the scheduled action will run."))
|
next_run = models.DateTimeField(null=True, default=None, editable=False, help_text=_("The next time that the scheduled action will run."))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_zoneinfo(self):
|
def get_zoneinfo(cls):
|
||||||
return sorted(get_zonefile_instance().zones)
|
return sorted(get_zonefile_instance().zones)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_zoneinfo_links(cls):
|
||||||
|
return_val = {}
|
||||||
|
zone_instance = get_zonefile_instance()
|
||||||
|
for zone_name in zone_instance.zones:
|
||||||
|
if str(zone_name) != str(zone_instance.zones[zone_name]._filename):
|
||||||
|
return_val[zone_name] = zone_instance.zones[zone_name]._filename
|
||||||
|
return return_val
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timezone(self):
|
def timezone(self):
|
||||||
utc = tzutc()
|
utc = tzutc()
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ class UnifiedJob(
|
|||||||
('workflow', _('Workflow')), # Job was started from a workflow job.
|
('workflow', _('Workflow')), # Job was started from a workflow job.
|
||||||
('webhook', _('Webhook')), # Job was started from a webhook event.
|
('webhook', _('Webhook')), # Job was started from a webhook event.
|
||||||
('sync', _('Sync')), # Job was started from a project sync.
|
('sync', _('Sync')), # Job was started from a project sync.
|
||||||
('scm', _('SCM Update')), # Job was created as an Inventory SCM sync.
|
('scm', _('SCM Update')), # (deprecated) Job was created as an Inventory SCM sync.
|
||||||
]
|
]
|
||||||
|
|
||||||
PASSWORD_FIELDS = ('start_args',)
|
PASSWORD_FIELDS = ('start_args',)
|
||||||
@@ -1204,6 +1204,10 @@ class UnifiedJob(
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_effective_artifacts(self, **kwargs):
|
||||||
|
"""Return unified job artifacts (from set_stats) to pass downstream in workflows"""
|
||||||
|
return {}
|
||||||
|
|
||||||
def get_passwords_needed_to_start(self):
|
def get_passwords_needed_to_start(self):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -318,8 +318,8 @@ class WorkflowJobNode(WorkflowNodeBase):
|
|||||||
for parent_node in self.get_parent_nodes():
|
for parent_node in self.get_parent_nodes():
|
||||||
is_root_node = False
|
is_root_node = False
|
||||||
aa_dict.update(parent_node.ancestor_artifacts)
|
aa_dict.update(parent_node.ancestor_artifacts)
|
||||||
if parent_node.job and hasattr(parent_node.job, 'artifacts'):
|
if parent_node.job:
|
||||||
aa_dict.update(parent_node.job.artifacts)
|
aa_dict.update(parent_node.job.get_effective_artifacts(parents_set=set([self.workflow_job_id])))
|
||||||
if aa_dict and not is_root_node:
|
if aa_dict and not is_root_node:
|
||||||
self.ancestor_artifacts = aa_dict
|
self.ancestor_artifacts = aa_dict
|
||||||
self.save(update_fields=['ancestor_artifacts'])
|
self.save(update_fields=['ancestor_artifacts'])
|
||||||
@@ -659,6 +659,13 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
|
|||||||
node_job_description = 'job #{0}, "{1}", which finished with status {2}.'.format(node.job.id, node.job.name, node.job.status)
|
node_job_description = 'job #{0}, "{1}", which finished with status {2}.'.format(node.job.id, node.job.name, node.job.status)
|
||||||
str_arr.append("- node #{0} spawns {1}".format(node.id, node_job_description))
|
str_arr.append("- node #{0} spawns {1}".format(node.id, node_job_description))
|
||||||
result['body'] = '\n'.join(str_arr)
|
result['body'] = '\n'.join(str_arr)
|
||||||
|
result.update(
|
||||||
|
dict(
|
||||||
|
inventory=self.inventory.name if self.inventory else None,
|
||||||
|
limit=self.limit,
|
||||||
|
extra_vars=self.display_extra_vars(),
|
||||||
|
)
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -682,6 +689,27 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
|
|||||||
wj = wj.get_workflow_job()
|
wj = wj.get_workflow_job()
|
||||||
return ancestors
|
return ancestors
|
||||||
|
|
||||||
|
def get_effective_artifacts(self, **kwargs):
|
||||||
|
"""
|
||||||
|
For downstream jobs of a workflow nested inside of a workflow,
|
||||||
|
we send aggregated artifacts from the nodes inside of the nested workflow
|
||||||
|
"""
|
||||||
|
artifacts = {}
|
||||||
|
job_queryset = (
|
||||||
|
UnifiedJob.objects.filter(unified_job_node__workflow_job=self)
|
||||||
|
.defer('job_args', 'job_cwd', 'start_args', 'result_traceback')
|
||||||
|
.order_by('finished', 'id')
|
||||||
|
.filter(status__in=['successful', 'failed'])
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
parents_set = kwargs.get('parents_set', set())
|
||||||
|
new_parents_set = parents_set | {self.id}
|
||||||
|
for job in job_queryset:
|
||||||
|
if job.id in parents_set:
|
||||||
|
continue
|
||||||
|
artifacts.update(job.get_effective_artifacts(parents_set=new_parents_set))
|
||||||
|
return artifacts
|
||||||
|
|
||||||
def get_notification_templates(self):
|
def get_notification_templates(self):
|
||||||
return self.workflow_job_template.notification_templates
|
return self.workflow_job_template.notification_templates
|
||||||
|
|
||||||
@@ -885,3 +913,12 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
@property
|
@property
|
||||||
def workflow_job(self):
|
def workflow_job(self):
|
||||||
return self.unified_job_node.workflow_job
|
return self.unified_job_node.workflow_job
|
||||||
|
|
||||||
|
def notification_data(self):
|
||||||
|
result = super(WorkflowApproval, self).notification_data()
|
||||||
|
result.update(
|
||||||
|
dict(
|
||||||
|
extra_vars=self.workflow_job.display_extra_vars(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import redis
|
|||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import awx.main.analytics.subsystem_metrics as s_metrics
|
|
||||||
|
|
||||||
__all__ = ['CallbackQueueDispatcher']
|
__all__ = ['CallbackQueueDispatcher']
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ class CallbackQueueDispatcher(object):
|
|||||||
self.queue = getattr(settings, 'CALLBACK_QUEUE', '')
|
self.queue = getattr(settings, 'CALLBACK_QUEUE', '')
|
||||||
self.logger = logging.getLogger('awx.main.queue.CallbackQueueDispatcher')
|
self.logger = logging.getLogger('awx.main.queue.CallbackQueueDispatcher')
|
||||||
self.connection = redis.Redis.from_url(settings.BROKER_URL)
|
self.connection = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
self.subsystem_metrics = s_metrics.Metrics()
|
|
||||||
|
|
||||||
def dispatch(self, obj):
|
def dispatch(self, obj):
|
||||||
self.connection.rpush(self.queue, json.dumps(obj, cls=AnsibleJSONEncoder))
|
self.connection.rpush(self.queue, json.dumps(obj, cls=AnsibleJSONEncoder))
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import signal
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.db import transaction, connection
|
from django.db import transaction, connection
|
||||||
@@ -38,12 +41,24 @@ from awx.main.constants import ACTIVE_STATES
|
|||||||
from awx.main.scheduler.dependency_graph import DependencyGraph
|
from awx.main.scheduler.dependency_graph import DependencyGraph
|
||||||
from awx.main.scheduler.task_manager_models import TaskManagerInstances
|
from awx.main.scheduler.task_manager_models import TaskManagerInstances
|
||||||
from awx.main.scheduler.task_manager_models import TaskManagerInstanceGroups
|
from awx.main.scheduler.task_manager_models import TaskManagerInstanceGroups
|
||||||
|
import awx.main.analytics.subsystem_metrics as s_metrics
|
||||||
from awx.main.utils import decrypt_field
|
from awx.main.utils import decrypt_field
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.scheduler')
|
logger = logging.getLogger('awx.main.scheduler')
|
||||||
|
|
||||||
|
|
||||||
|
def timeit(func):
|
||||||
|
def inner(*args, **kwargs):
|
||||||
|
t_now = time.perf_counter()
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
dur = time.perf_counter() - t_now
|
||||||
|
args[0].subsystem_metrics.inc("task_manager_" + func.__name__ + "_seconds", dur)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
||||||
class TaskManager:
|
class TaskManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""
|
"""
|
||||||
@@ -62,6 +77,13 @@ class TaskManager:
|
|||||||
# will no longer be started and will be started on the next task manager cycle.
|
# will no longer be started and will be started on the next task manager cycle.
|
||||||
self.start_task_limit = settings.START_TASK_LIMIT
|
self.start_task_limit = settings.START_TASK_LIMIT
|
||||||
self.time_delta_job_explanation = timedelta(seconds=30)
|
self.time_delta_job_explanation = timedelta(seconds=30)
|
||||||
|
self.subsystem_metrics = s_metrics.Metrics(auto_pipe_execute=False)
|
||||||
|
# initialize each metric to 0 and force metric_has_changed to true. This
|
||||||
|
# ensures each task manager metric will be overridden when pipe_execute
|
||||||
|
# is called later.
|
||||||
|
for m in self.subsystem_metrics.METRICS:
|
||||||
|
if m.startswith("task_manager"):
|
||||||
|
self.subsystem_metrics.set(m, 0)
|
||||||
|
|
||||||
def after_lock_init(self, all_sorted_tasks):
|
def after_lock_init(self, all_sorted_tasks):
|
||||||
"""
|
"""
|
||||||
@@ -100,6 +122,7 @@ class TaskManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@timeit
|
||||||
def get_tasks(self, status_list=('pending', 'waiting', 'running')):
|
def get_tasks(self, status_list=('pending', 'waiting', 'running')):
|
||||||
jobs = [j for j in Job.objects.filter(status__in=status_list).prefetch_related('instance_group')]
|
jobs = [j for j in Job.objects.filter(status__in=status_list).prefetch_related('instance_group')]
|
||||||
inventory_updates_qs = (
|
inventory_updates_qs = (
|
||||||
@@ -125,6 +148,7 @@ class TaskManager:
|
|||||||
inventory_ids.add(task.inventory_id)
|
inventory_ids.add(task.inventory_id)
|
||||||
return [invsrc for invsrc in InventorySource.objects.filter(inventory_id__in=inventory_ids, update_on_launch=True)]
|
return [invsrc for invsrc in InventorySource.objects.filter(inventory_id__in=inventory_ids, update_on_launch=True)]
|
||||||
|
|
||||||
|
@timeit
|
||||||
def spawn_workflow_graph_jobs(self, workflow_jobs):
|
def spawn_workflow_graph_jobs(self, workflow_jobs):
|
||||||
for workflow_job in workflow_jobs:
|
for workflow_job in workflow_jobs:
|
||||||
if workflow_job.cancel_flag:
|
if workflow_job.cancel_flag:
|
||||||
@@ -224,14 +248,16 @@ class TaskManager:
|
|||||||
workflow_job.save(update_fields=update_fields)
|
workflow_job.save(update_fields=update_fields)
|
||||||
status_changed = True
|
status_changed = True
|
||||||
if status_changed:
|
if status_changed:
|
||||||
|
if workflow_job.spawned_by_workflow:
|
||||||
|
schedule_task_manager()
|
||||||
workflow_job.websocket_emit_status(workflow_job.status)
|
workflow_job.websocket_emit_status(workflow_job.status)
|
||||||
# Operations whose queries rely on modifications made during the atomic scheduling session
|
# Operations whose queries rely on modifications made during the atomic scheduling session
|
||||||
workflow_job.send_notification_templates('succeeded' if workflow_job.status == 'successful' else 'failed')
|
workflow_job.send_notification_templates('succeeded' if workflow_job.status == 'successful' else 'failed')
|
||||||
if workflow_job.spawned_by_workflow:
|
|
||||||
schedule_task_manager()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@timeit
|
||||||
def start_task(self, task, instance_group, dependent_tasks=None, instance=None):
|
def start_task(self, task, instance_group, dependent_tasks=None, instance=None):
|
||||||
|
self.subsystem_metrics.inc("task_manager_tasks_started", 1)
|
||||||
self.start_task_limit -= 1
|
self.start_task_limit -= 1
|
||||||
if self.start_task_limit == 0:
|
if self.start_task_limit == 0:
|
||||||
# schedule another run immediately after this task manager
|
# schedule another run immediately after this task manager
|
||||||
@@ -291,6 +317,7 @@ class TaskManager:
|
|||||||
task.websocket_emit_status(task.status) # adds to on_commit
|
task.websocket_emit_status(task.status) # adds to on_commit
|
||||||
connection.on_commit(post_commit)
|
connection.on_commit(post_commit)
|
||||||
|
|
||||||
|
@timeit
|
||||||
def process_running_tasks(self, running_tasks):
|
def process_running_tasks(self, running_tasks):
|
||||||
for task in running_tasks:
|
for task in running_tasks:
|
||||||
self.dependency_graph.add_job(task)
|
self.dependency_graph.add_job(task)
|
||||||
@@ -439,6 +466,7 @@ class TaskManager:
|
|||||||
latest_src_project_update.scm_inventory_updates.add(inventory_task)
|
latest_src_project_update.scm_inventory_updates.add(inventory_task)
|
||||||
return created_dependencies
|
return created_dependencies
|
||||||
|
|
||||||
|
@timeit
|
||||||
def generate_dependencies(self, undeped_tasks):
|
def generate_dependencies(self, undeped_tasks):
|
||||||
created_dependencies = []
|
created_dependencies = []
|
||||||
for task in undeped_tasks:
|
for task in undeped_tasks:
|
||||||
@@ -453,6 +481,7 @@ class TaskManager:
|
|||||||
|
|
||||||
return created_dependencies
|
return created_dependencies
|
||||||
|
|
||||||
|
@timeit
|
||||||
def process_pending_tasks(self, pending_tasks):
|
def process_pending_tasks(self, pending_tasks):
|
||||||
running_workflow_templates = {wf.unified_job_template_id for wf in self.get_running_workflow_jobs()}
|
running_workflow_templates = {wf.unified_job_template_id for wf in self.get_running_workflow_jobs()}
|
||||||
tasks_to_update_job_explanation = []
|
tasks_to_update_job_explanation = []
|
||||||
@@ -461,6 +490,7 @@ class TaskManager:
|
|||||||
break
|
break
|
||||||
blocked_by = self.job_blocked_by(task)
|
blocked_by = self.job_blocked_by(task)
|
||||||
if blocked_by:
|
if blocked_by:
|
||||||
|
self.subsystem_metrics.inc("task_manager_tasks_blocked", 1)
|
||||||
task.log_lifecycle("blocked", blocked_by=blocked_by)
|
task.log_lifecycle("blocked", blocked_by=blocked_by)
|
||||||
job_explanation = gettext_noop(f"waiting for {blocked_by._meta.model_name}-{blocked_by.id} to finish")
|
job_explanation = gettext_noop(f"waiting for {blocked_by._meta.model_name}-{blocked_by.id} to finish")
|
||||||
if task.job_explanation != job_explanation:
|
if task.job_explanation != job_explanation:
|
||||||
@@ -602,17 +632,22 @@ class TaskManager:
|
|||||||
|
|
||||||
def process_tasks(self, all_sorted_tasks):
|
def process_tasks(self, all_sorted_tasks):
|
||||||
running_tasks = [t for t in all_sorted_tasks if t.status in ['waiting', 'running']]
|
running_tasks = [t for t in all_sorted_tasks if t.status in ['waiting', 'running']]
|
||||||
|
|
||||||
self.process_running_tasks(running_tasks)
|
self.process_running_tasks(running_tasks)
|
||||||
|
self.subsystem_metrics.inc("task_manager_running_processed", len(running_tasks))
|
||||||
|
|
||||||
pending_tasks = [t for t in all_sorted_tasks if t.status == 'pending']
|
pending_tasks = [t for t in all_sorted_tasks if t.status == 'pending']
|
||||||
|
|
||||||
undeped_tasks = [t for t in pending_tasks if not t.dependencies_processed]
|
undeped_tasks = [t for t in pending_tasks if not t.dependencies_processed]
|
||||||
dependencies = self.generate_dependencies(undeped_tasks)
|
dependencies = self.generate_dependencies(undeped_tasks)
|
||||||
deps_of_deps = self.generate_dependencies(dependencies)
|
deps_of_deps = self.generate_dependencies(dependencies)
|
||||||
dependencies += deps_of_deps
|
dependencies += deps_of_deps
|
||||||
self.process_pending_tasks(dependencies)
|
self.process_pending_tasks(dependencies)
|
||||||
self.process_pending_tasks(pending_tasks)
|
self.subsystem_metrics.inc("task_manager_pending_processed", len(dependencies))
|
||||||
|
|
||||||
|
self.process_pending_tasks(pending_tasks)
|
||||||
|
self.subsystem_metrics.inc("task_manager_pending_processed", len(pending_tasks))
|
||||||
|
|
||||||
|
@timeit
|
||||||
def _schedule(self):
|
def _schedule(self):
|
||||||
finished_wfjs = []
|
finished_wfjs = []
|
||||||
all_sorted_tasks = self.get_tasks()
|
all_sorted_tasks = self.get_tasks()
|
||||||
@@ -648,6 +683,28 @@ class TaskManager:
|
|||||||
self.process_tasks(all_sorted_tasks)
|
self.process_tasks(all_sorted_tasks)
|
||||||
return finished_wfjs
|
return finished_wfjs
|
||||||
|
|
||||||
|
def record_aggregate_metrics(self, *args):
|
||||||
|
if not settings.IS_TESTING():
|
||||||
|
# increment task_manager_schedule_calls regardless if the other
|
||||||
|
# metrics are recorded
|
||||||
|
s_metrics.Metrics(auto_pipe_execute=True).inc("task_manager_schedule_calls", 1)
|
||||||
|
# Only record metrics if the last time recording was more
|
||||||
|
# than SUBSYSTEM_METRICS_TASK_MANAGER_RECORD_INTERVAL ago.
|
||||||
|
# Prevents a short-duration task manager that runs directly after a
|
||||||
|
# long task manager to override useful metrics.
|
||||||
|
current_time = time.time()
|
||||||
|
time_last_recorded = current_time - self.subsystem_metrics.decode("task_manager_recorded_timestamp")
|
||||||
|
if time_last_recorded > settings.SUBSYSTEM_METRICS_TASK_MANAGER_RECORD_INTERVAL:
|
||||||
|
logger.debug(f"recording metrics, last recorded {time_last_recorded} seconds ago")
|
||||||
|
self.subsystem_metrics.set("task_manager_recorded_timestamp", current_time)
|
||||||
|
self.subsystem_metrics.pipe_execute()
|
||||||
|
else:
|
||||||
|
logger.debug(f"skipping recording metrics, last recorded {time_last_recorded} seconds ago")
|
||||||
|
|
||||||
|
def record_aggregate_metrics_and_exit(self, *args):
|
||||||
|
self.record_aggregate_metrics()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
def schedule(self):
|
def schedule(self):
|
||||||
# Lock
|
# Lock
|
||||||
with advisory_lock('task_manager_lock', wait=False) as acquired:
|
with advisory_lock('task_manager_lock', wait=False) as acquired:
|
||||||
@@ -657,5 +714,8 @@ class TaskManager:
|
|||||||
return
|
return
|
||||||
logger.debug("Starting Scheduler")
|
logger.debug("Starting Scheduler")
|
||||||
with task_manager_bulk_reschedule():
|
with task_manager_bulk_reschedule():
|
||||||
|
# if sigterm due to timeout, still record metrics
|
||||||
|
signal.signal(signal.SIGTERM, self.record_aggregate_metrics_and_exit)
|
||||||
self._schedule()
|
self._schedule()
|
||||||
|
self.record_aggregate_metrics()
|
||||||
logger.debug("Finishing Scheduler")
|
logger.debug("Finishing Scheduler")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from awx.main.redact import UriCleaner
|
|||||||
from awx.main.constants import MINIMAL_EVENTS, ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE
|
from awx.main.constants import MINIMAL_EVENTS, ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE
|
||||||
from awx.main.utils.update_model import update_model
|
from awx.main.utils.update_model import update_model
|
||||||
from awx.main.queue import CallbackQueueDispatcher
|
from awx.main.queue import CallbackQueueDispatcher
|
||||||
|
from awx.main.tasks.signals import signal_callback
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.tasks.callback')
|
logger = logging.getLogger('awx.main.tasks.callback')
|
||||||
|
|
||||||
@@ -179,7 +180,13 @@ class RunnerCallback:
|
|||||||
Ansible runner callback to tell the job when/if it is canceled
|
Ansible runner callback to tell the job when/if it is canceled
|
||||||
"""
|
"""
|
||||||
unified_job_id = self.instance.pk
|
unified_job_id = self.instance.pk
|
||||||
self.instance = self.update_model(unified_job_id)
|
if signal_callback():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
self.instance = self.update_model(unified_job_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f'Encountered error during cancel check for {unified_job_id}, canceling now')
|
||||||
|
return True
|
||||||
if not self.instance:
|
if not self.instance:
|
||||||
logger.error('unified job {} was deleted while running, canceling'.format(unified_job_id))
|
logger.error('unified job {} was deleted while running, canceling'.format(unified_job_id))
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from uuid import uuid4
|
|||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
|
|
||||||
# Runner
|
# Runner
|
||||||
@@ -34,7 +33,6 @@ from gitdb.exc import BadName as BadGitName
|
|||||||
from awx.main.dispatch.publish import task
|
from awx.main.dispatch.publish import task
|
||||||
from awx.main.dispatch import get_local_queuename
|
from awx.main.dispatch import get_local_queuename
|
||||||
from awx.main.constants import (
|
from awx.main.constants import (
|
||||||
ACTIVE_STATES,
|
|
||||||
PRIVILEGE_ESCALATION_METHODS,
|
PRIVILEGE_ESCALATION_METHODS,
|
||||||
STANDARD_INVENTORY_UPDATE_ENV,
|
STANDARD_INVENTORY_UPDATE_ENV,
|
||||||
JOB_FOLDER_PREFIX,
|
JOB_FOLDER_PREFIX,
|
||||||
@@ -64,6 +62,7 @@ from awx.main.tasks.callback import (
|
|||||||
RunnerCallbackForProjectUpdate,
|
RunnerCallbackForProjectUpdate,
|
||||||
RunnerCallbackForSystemJob,
|
RunnerCallbackForSystemJob,
|
||||||
)
|
)
|
||||||
|
from awx.main.tasks.signals import with_signal_handling, signal_callback
|
||||||
from awx.main.tasks.receptor import AWXReceptorJob
|
from awx.main.tasks.receptor import AWXReceptorJob
|
||||||
from awx.main.exceptions import AwxTaskError, PostRunError, ReceptorNodeNotFound
|
from awx.main.exceptions import AwxTaskError, PostRunError, ReceptorNodeNotFound
|
||||||
from awx.main.utils.ansible import read_ansible_config
|
from awx.main.utils.ansible import read_ansible_config
|
||||||
@@ -394,6 +393,7 @@ class BaseTask(object):
|
|||||||
instance.save(update_fields=['ansible_version'])
|
instance.save(update_fields=['ansible_version'])
|
||||||
|
|
||||||
@with_path_cleanup
|
@with_path_cleanup
|
||||||
|
@with_signal_handling
|
||||||
def run(self, pk, **kwargs):
|
def run(self, pk, **kwargs):
|
||||||
"""
|
"""
|
||||||
Run the job/task and capture its output.
|
Run the job/task and capture its output.
|
||||||
@@ -425,7 +425,7 @@ class BaseTask(object):
|
|||||||
private_data_dir = self.build_private_data_dir(self.instance)
|
private_data_dir = self.build_private_data_dir(self.instance)
|
||||||
self.pre_run_hook(self.instance, private_data_dir)
|
self.pre_run_hook(self.instance, private_data_dir)
|
||||||
self.instance.log_lifecycle("preparing_playbook")
|
self.instance.log_lifecycle("preparing_playbook")
|
||||||
if self.instance.cancel_flag:
|
if self.instance.cancel_flag or signal_callback():
|
||||||
self.instance = self.update_model(self.instance.pk, status='canceled')
|
self.instance = self.update_model(self.instance.pk, status='canceled')
|
||||||
if self.instance.status != 'running':
|
if self.instance.status != 'running':
|
||||||
# Stop the task chain and prevent starting the job if it has
|
# Stop the task chain and prevent starting the job if it has
|
||||||
@@ -547,6 +547,11 @@ class BaseTask(object):
|
|||||||
self.runner_callback.delay_update(skip_if_already_set=True, job_explanation=f"Job terminated due to {status}")
|
self.runner_callback.delay_update(skip_if_already_set=True, job_explanation=f"Job terminated due to {status}")
|
||||||
if status == 'timeout':
|
if status == 'timeout':
|
||||||
status = 'failed'
|
status = 'failed'
|
||||||
|
elif status == 'canceled':
|
||||||
|
self.instance = self.update_model(pk)
|
||||||
|
if (getattr(self.instance, 'cancel_flag', False) is False) and signal_callback():
|
||||||
|
self.runner_callback.delay_update(job_explanation="Task was canceled due to receiving a shutdown signal.")
|
||||||
|
status = 'failed'
|
||||||
except ReceptorNodeNotFound as exc:
|
except ReceptorNodeNotFound as exc:
|
||||||
self.runner_callback.delay_update(job_explanation=str(exc))
|
self.runner_callback.delay_update(job_explanation=str(exc))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1168,64 +1173,6 @@ class RunProjectUpdate(BaseTask):
|
|||||||
d[r'^Are you sure you want to continue connecting \(yes/no\)\?\s*?$'] = 'yes'
|
d[r'^Are you sure you want to continue connecting \(yes/no\)\?\s*?$'] = 'yes'
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def _update_dependent_inventories(self, project_update, dependent_inventory_sources):
|
|
||||||
scm_revision = project_update.project.scm_revision
|
|
||||||
inv_update_class = InventoryUpdate._get_task_class()
|
|
||||||
for inv_src in dependent_inventory_sources:
|
|
||||||
if not inv_src.update_on_project_update:
|
|
||||||
continue
|
|
||||||
if inv_src.scm_last_revision == scm_revision:
|
|
||||||
logger.debug('Skipping SCM inventory update for `{}` because ' 'project has not changed.'.format(inv_src.name))
|
|
||||||
continue
|
|
||||||
logger.debug('Local dependent inventory update for `{}`.'.format(inv_src.name))
|
|
||||||
with transaction.atomic():
|
|
||||||
if InventoryUpdate.objects.filter(inventory_source=inv_src, status__in=ACTIVE_STATES).exists():
|
|
||||||
logger.debug('Skipping SCM inventory update for `{}` because ' 'another update is already active.'.format(inv_src.name))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if settings.IS_K8S:
|
|
||||||
instance_group = InventoryUpdate(inventory_source=inv_src).preferred_instance_groups[0]
|
|
||||||
else:
|
|
||||||
instance_group = project_update.instance_group
|
|
||||||
|
|
||||||
local_inv_update = inv_src.create_inventory_update(
|
|
||||||
_eager_fields=dict(
|
|
||||||
launch_type='scm',
|
|
||||||
status='running',
|
|
||||||
instance_group=instance_group,
|
|
||||||
execution_node=project_update.execution_node,
|
|
||||||
controller_node=project_update.execution_node,
|
|
||||||
source_project_update=project_update,
|
|
||||||
celery_task_id=project_update.celery_task_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
local_inv_update.log_lifecycle("controller_node_chosen")
|
|
||||||
local_inv_update.log_lifecycle("execution_node_chosen")
|
|
||||||
try:
|
|
||||||
create_partition(local_inv_update.event_class._meta.db_table, start=local_inv_update.created)
|
|
||||||
inv_update_class().run(local_inv_update.id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception('{} Unhandled exception updating dependent SCM inventory sources.'.format(project_update.log_format))
|
|
||||||
|
|
||||||
try:
|
|
||||||
project_update.refresh_from_db()
|
|
||||||
except ProjectUpdate.DoesNotExist:
|
|
||||||
logger.warning('Project update deleted during updates of dependent SCM inventory sources.')
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
local_inv_update.refresh_from_db()
|
|
||||||
except InventoryUpdate.DoesNotExist:
|
|
||||||
logger.warning('%s Dependent inventory update deleted during execution.', project_update.log_format)
|
|
||||||
continue
|
|
||||||
if project_update.cancel_flag:
|
|
||||||
logger.info('Project update {} was canceled while updating dependent inventories.'.format(project_update.log_format))
|
|
||||||
break
|
|
||||||
if local_inv_update.cancel_flag:
|
|
||||||
logger.info('Continuing to process project dependencies after {} was canceled'.format(local_inv_update.log_format))
|
|
||||||
if local_inv_update.status == 'successful':
|
|
||||||
inv_src.scm_last_revision = scm_revision
|
|
||||||
inv_src.save(update_fields=['scm_last_revision'])
|
|
||||||
|
|
||||||
def release_lock(self, instance):
|
def release_lock(self, instance):
|
||||||
try:
|
try:
|
||||||
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
|
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
|
||||||
@@ -1435,12 +1382,6 @@ class RunProjectUpdate(BaseTask):
|
|||||||
p.inventory_files = p.inventories
|
p.inventory_files = p.inventories
|
||||||
p.save(update_fields=['scm_revision', 'playbook_files', 'inventory_files'])
|
p.save(update_fields=['scm_revision', 'playbook_files', 'inventory_files'])
|
||||||
|
|
||||||
# Update any inventories that depend on this project
|
|
||||||
dependent_inventory_sources = p.scm_inventory_sources.filter(update_on_project_update=True)
|
|
||||||
if len(dependent_inventory_sources) > 0:
|
|
||||||
if status == 'successful' and instance.launch_type != 'sync':
|
|
||||||
self._update_dependent_inventories(instance, dependent_inventory_sources)
|
|
||||||
|
|
||||||
def build_execution_environment_params(self, instance, private_data_dir):
|
def build_execution_environment_params(self, instance, private_data_dir):
|
||||||
if settings.IS_K8S:
|
if settings.IS_K8S:
|
||||||
return {}
|
return {}
|
||||||
@@ -1620,9 +1561,7 @@ class RunInventoryUpdate(BaseTask):
|
|||||||
source_project = None
|
source_project = None
|
||||||
if inventory_update.inventory_source:
|
if inventory_update.inventory_source:
|
||||||
source_project = inventory_update.inventory_source.source_project
|
source_project = inventory_update.inventory_source.source_project
|
||||||
if (
|
if inventory_update.source == 'scm' and source_project and source_project.scm_type: # never ever update manual projects
|
||||||
inventory_update.source == 'scm' and inventory_update.launch_type != 'scm' and source_project and source_project.scm_type
|
|
||||||
): # never ever update manual projects
|
|
||||||
|
|
||||||
# Check if the content cache exists, so that we do not unnecessarily re-download roles
|
# Check if the content cache exists, so that we do not unnecessarily re-download roles
|
||||||
sync_needs = ['update_{}'.format(source_project.scm_type)]
|
sync_needs = ['update_{}'.format(source_project.scm_type)]
|
||||||
@@ -1655,8 +1594,6 @@ class RunInventoryUpdate(BaseTask):
|
|||||||
sync_task = project_update_task(job_private_data_dir=private_data_dir)
|
sync_task = project_update_task(job_private_data_dir=private_data_dir)
|
||||||
sync_task.run(local_project_sync.id)
|
sync_task.run(local_project_sync.id)
|
||||||
local_project_sync.refresh_from_db()
|
local_project_sync.refresh_from_db()
|
||||||
inventory_update.inventory_source.scm_last_revision = local_project_sync.scm_revision
|
|
||||||
inventory_update.inventory_source.save(update_fields=['scm_last_revision'])
|
|
||||||
except Exception:
|
except Exception:
|
||||||
inventory_update = self.update_model(
|
inventory_update = self.update_model(
|
||||||
inventory_update.pk,
|
inventory_update.pk,
|
||||||
@@ -1667,9 +1604,6 @@ class RunInventoryUpdate(BaseTask):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
elif inventory_update.source == 'scm' and inventory_update.launch_type == 'scm' and source_project:
|
|
||||||
# This follows update, not sync, so make copy here
|
|
||||||
RunProjectUpdate.make_local_copy(source_project, private_data_dir)
|
|
||||||
|
|
||||||
def post_run_hook(self, inventory_update, status):
|
def post_run_hook(self, inventory_update, status):
|
||||||
super(RunInventoryUpdate, self).post_run_hook(inventory_update, status)
|
super(RunInventoryUpdate, self).post_run_hook(inventory_update, status)
|
||||||
|
|||||||
63
awx/main/tasks/signals.py
Normal file
63
awx/main/tasks/signals.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import signal
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('awx.main.tasks.signals')
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['with_signal_handling', 'signal_callback']
|
||||||
|
|
||||||
|
|
||||||
|
class SignalState:
|
||||||
|
def reset(self):
|
||||||
|
self.sigterm_flag = False
|
||||||
|
self.is_active = False
|
||||||
|
self.original_sigterm = None
|
||||||
|
self.original_sigint = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def set_flag(self, *args):
|
||||||
|
"""Method to pass into the python signal.signal method to receive signals"""
|
||||||
|
self.sigterm_flag = True
|
||||||
|
|
||||||
|
def connect_signals(self):
|
||||||
|
self.original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
|
self.original_sigint = signal.getsignal(signal.SIGINT)
|
||||||
|
signal.signal(signal.SIGTERM, self.set_flag)
|
||||||
|
signal.signal(signal.SIGINT, self.set_flag)
|
||||||
|
self.is_active = True
|
||||||
|
|
||||||
|
def restore_signals(self):
|
||||||
|
signal.signal(signal.SIGTERM, self.original_sigterm)
|
||||||
|
signal.signal(signal.SIGINT, self.original_sigint)
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
|
signal_state = SignalState()
|
||||||
|
|
||||||
|
|
||||||
|
def signal_callback():
|
||||||
|
return signal_state.sigterm_flag
|
||||||
|
|
||||||
|
|
||||||
|
def with_signal_handling(f):
|
||||||
|
"""
|
||||||
|
Change signal handling to make signal_callback return True in event of SIGTERM or SIGINT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@functools.wraps(f)
|
||||||
|
def _wrapped(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
this_is_outermost_caller = False
|
||||||
|
if not signal_state.is_active:
|
||||||
|
signal_state.connect_signals()
|
||||||
|
this_is_outermost_caller = True
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
if this_is_outermost_caller:
|
||||||
|
signal_state.restore_signals()
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
@@ -103,7 +103,8 @@ def dispatch_startup():
|
|||||||
#
|
#
|
||||||
apply_cluster_membership_policies()
|
apply_cluster_membership_policies()
|
||||||
cluster_node_heartbeat()
|
cluster_node_heartbeat()
|
||||||
Metrics().clear_values()
|
m = Metrics()
|
||||||
|
m.reset_values()
|
||||||
|
|
||||||
# Update Tower's rsyslog.conf file based on loggins settings in the db
|
# Update Tower's rsyslog.conf file based on loggins settings in the db
|
||||||
reconfigure_rsyslog()
|
reconfigure_rsyslog()
|
||||||
@@ -113,10 +114,6 @@ def inform_cluster_of_shutdown():
|
|||||||
try:
|
try:
|
||||||
this_inst = Instance.objects.get(hostname=settings.CLUSTER_HOST_ID)
|
this_inst = Instance.objects.get(hostname=settings.CLUSTER_HOST_ID)
|
||||||
this_inst.mark_offline(update_last_seen=True, errors=_('Instance received normal shutdown signal'))
|
this_inst.mark_offline(update_last_seen=True, errors=_('Instance received normal shutdown signal'))
|
||||||
try:
|
|
||||||
reaper.reap(this_inst)
|
|
||||||
except Exception:
|
|
||||||
logger.exception('failed to reap jobs for {}'.format(this_inst.hostname))
|
|
||||||
logger.warning('Normal shutdown signal for instance {}, ' 'removed self from capacity pool.'.format(this_inst.hostname))
|
logger.warning('Normal shutdown signal for instance {}, ' 'removed self from capacity pool.'.format(this_inst.hostname))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Encountered problem with normal shutdown signal.')
|
logger.exception('Encountered problem with normal shutdown signal.')
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
- ansible.builtin.import_playbook: foo
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
- ansible.builtin.include: foo
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"ANSIBLE_JINJA2_NATIVE": "True",
|
"ANSIBLE_JINJA2_NATIVE": "True",
|
||||||
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
|
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
|
||||||
"GCE_CREDENTIALS_FILE_PATH": "{{ file_reference }}",
|
"GCE_CREDENTIALS_FILE_PATH": "{{ file_reference }}",
|
||||||
|
"GOOGLE_APPLICATION_CREDENTIALS": "{{ file_reference }}",
|
||||||
"GCP_AUTH_KIND": "serviceaccount",
|
"GCP_AUTH_KIND": "serviceaccount",
|
||||||
"GCP_ENV_TYPE": "tower",
|
"GCP_ENV_TYPE": "tower",
|
||||||
"GCP_PROJECT": "fooo",
|
"GCP_PROJECT": "fooo",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def test_empty():
|
|||||||
"workflow_job_template": 0,
|
"workflow_job_template": 0,
|
||||||
"unified_job": 0,
|
"unified_job": 0,
|
||||||
"pending_jobs": 0,
|
"pending_jobs": 0,
|
||||||
|
"database_connections": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ EXPECTED_VALUES = {
|
|||||||
'awx_license_instance_total': 0,
|
'awx_license_instance_total': 0,
|
||||||
'awx_license_instance_free': 0,
|
'awx_license_instance_free': 0,
|
||||||
'awx_pending_jobs_total': 0,
|
'awx_pending_jobs_total': 0,
|
||||||
|
'awx_database_connections_total': 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ from awx.api.versioning import reverse
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def ec2_source(inventory, project):
|
def ec2_source(inventory, project):
|
||||||
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
||||||
return inventory.inventory_sources.create(
|
return inventory.inventory_sources.create(name='some_source', source='ec2', source_project=project)
|
||||||
name='some_source', update_on_project_update=True, source='ec2', source_project=project, scm_last_revision=project.scm_revision
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ def test_job_job_events_children_summary(get, organization_factory, job_template
|
|||||||
job_id=job.pk, uuid='uuid2', parent_uuid='uuid1', event="playbook_on_play_start", counter=2, stdout='a' * 1024, job_created=job.created
|
job_id=job.pk, uuid='uuid2', parent_uuid='uuid1', event="playbook_on_play_start", counter=2, stdout='a' * 1024, job_created=job.created
|
||||||
).save()
|
).save()
|
||||||
JobEvent.create_from_data(
|
JobEvent.create_from_data(
|
||||||
job_id=job.pk, uuid='uuid3', parent_uuid='uuid2', event="runner_on_start", counter=3, stdout='a' * 1024, job_created=job.created
|
job_id=job.pk, uuid='uuid3', parent_uuid='uuid2', event="playbook_on_task_start", counter=3, stdout='a' * 1024, job_created=job.created
|
||||||
).save()
|
).save()
|
||||||
JobEvent.create_from_data(job_id=job.pk, uuid='uuid4', parent_uuid='', event='verbose', counter=4, stdout='a' * 1024, job_created=job.created).save()
|
JobEvent.create_from_data(job_id=job.pk, uuid='uuid4', parent_uuid='', event='verbose', counter=4, stdout='a' * 1024, job_created=job.created).save()
|
||||||
JobEvent.create_from_data(
|
JobEvent.create_from_data(
|
||||||
job_id=job.pk, uuid='uuid5', parent_uuid='uuid1', event="playbook_on_task_start", counter=5, stdout='a' * 1024, job_created=job.created
|
job_id=job.pk, uuid='uuid5', parent_uuid='uuid1', event="playbook_on_play_start", counter=5, stdout='a' * 1024, job_created=job.created
|
||||||
).save()
|
).save()
|
||||||
job.emitted_events = job.get_event_queryset().count()
|
job.emitted_events = job.get_event_queryset().count()
|
||||||
job.status = "successful"
|
job.status = "successful"
|
||||||
@@ -84,3 +84,50 @@ def test_job_job_events_children_summary(get, organization_factory, job_template
|
|||||||
assert response.data["children_summary"] == {1: {"rowNumber": 0, "numChildren": 4}, 2: {"rowNumber": 1, "numChildren": 2}}
|
assert response.data["children_summary"] == {1: {"rowNumber": 0, "numChildren": 4}, 2: {"rowNumber": 1, "numChildren": 2}}
|
||||||
assert response.data["meta_event_nested_uuid"] == {4: "uuid2"}
|
assert response.data["meta_event_nested_uuid"] == {4: "uuid2"}
|
||||||
assert response.data["event_processing_finished"] == True
|
assert response.data["event_processing_finished"] == True
|
||||||
|
assert response.data["is_tree"] == True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_job_job_events_children_summary_is_tree(get, organization_factory, job_template_factory):
|
||||||
|
'''
|
||||||
|
children_summary should return {is_tree: False} if the event structure is not tree-like
|
||||||
|
'''
|
||||||
|
objs = organization_factory("org", superusers=['admin'])
|
||||||
|
jt = job_template_factory("jt", organization=objs.organization, inventory='test_inv', project='test_proj').job_template
|
||||||
|
job = jt.create_unified_job()
|
||||||
|
url = reverse('api:job_job_events_children_summary', kwargs={'pk': job.pk})
|
||||||
|
response = get(url, user=objs.superusers.admin, expect=200)
|
||||||
|
assert response.data["event_processing_finished"] == False
|
||||||
|
'''
|
||||||
|
E1
|
||||||
|
E2
|
||||||
|
E3
|
||||||
|
E4 (verbose)
|
||||||
|
E5
|
||||||
|
E6 <-- parent is E2, but comes after another "branch" E5
|
||||||
|
'''
|
||||||
|
JobEvent.create_from_data(
|
||||||
|
job_id=job.pk, uuid='uuid1', parent_uuid='', event="playbook_on_start", counter=1, stdout='a' * 1024, job_created=job.created
|
||||||
|
).save()
|
||||||
|
JobEvent.create_from_data(
|
||||||
|
job_id=job.pk, uuid='uuid2', parent_uuid='uuid1', event="playbook_on_play_start", counter=2, stdout='a' * 1024, job_created=job.created
|
||||||
|
).save()
|
||||||
|
JobEvent.create_from_data(
|
||||||
|
job_id=job.pk, uuid='uuid3', parent_uuid='uuid2', event="playbook_on_task_start", counter=3, stdout='a' * 1024, job_created=job.created
|
||||||
|
).save()
|
||||||
|
JobEvent.create_from_data(job_id=job.pk, uuid='uuid4', parent_uuid='', event='verbose', counter=4, stdout='a' * 1024, job_created=job.created).save()
|
||||||
|
JobEvent.create_from_data(
|
||||||
|
job_id=job.pk, uuid='uuid5', parent_uuid='uuid1', event="playbook_on_play_start", counter=5, stdout='a' * 1024, job_created=job.created
|
||||||
|
).save()
|
||||||
|
JobEvent.create_from_data(
|
||||||
|
job_id=job.pk, uuid='uuid6', parent_uuid='uuid2', event="playbook_on_task_start", counter=6, stdout='a' * 1024, job_created=job.created
|
||||||
|
).save()
|
||||||
|
job.emitted_events = job.get_event_queryset().count()
|
||||||
|
job.status = "successful"
|
||||||
|
job.save()
|
||||||
|
url = reverse('api:job_job_events_children_summary', kwargs={'pk': job.pk})
|
||||||
|
response = get(url, user=objs.superusers.admin, expect=200)
|
||||||
|
assert response.data["children_summary"] == {}
|
||||||
|
assert response.data["meta_event_nested_uuid"] == {}
|
||||||
|
assert response.data["event_processing_finished"] == True
|
||||||
|
assert response.data["is_tree"] == False
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ from awx.main.models import InventorySource, Inventory, ActivityStream
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def scm_inventory(inventory, project):
|
def scm_inventory(inventory, project):
|
||||||
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
||||||
inventory.inventory_sources.create(
|
inventory.inventory_sources.create(name='foobar', source='scm', source_project=project)
|
||||||
name='foobar', update_on_project_update=True, source='scm', source_project=project, scm_last_revision=project.scm_revision
|
|
||||||
)
|
|
||||||
return inventory
|
return inventory
|
||||||
|
|
||||||
|
|
||||||
@@ -23,9 +21,7 @@ def scm_inventory(inventory, project):
|
|||||||
def factory_scm_inventory(inventory, project):
|
def factory_scm_inventory(inventory, project):
|
||||||
def fn(**kwargs):
|
def fn(**kwargs):
|
||||||
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
||||||
return inventory.inventory_sources.create(
|
return inventory.inventory_sources.create(source_project=project, overwrite_vars=True, source='scm', **kwargs)
|
||||||
source_project=project, overwrite_vars=True, source='scm', scm_last_revision=project.scm_revision, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
@@ -544,15 +540,12 @@ class TestControlledBySCM:
|
|||||||
def test_safe_method_works(self, get, options, scm_inventory, admin_user):
|
def test_safe_method_works(self, get, options, scm_inventory, admin_user):
|
||||||
get(scm_inventory.get_absolute_url(), admin_user, expect=200)
|
get(scm_inventory.get_absolute_url(), admin_user, expect=200)
|
||||||
options(scm_inventory.get_absolute_url(), admin_user, expect=200)
|
options(scm_inventory.get_absolute_url(), admin_user, expect=200)
|
||||||
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision != ''
|
|
||||||
|
|
||||||
def test_vars_edit_reset(self, patch, scm_inventory, admin_user):
|
def test_vars_edit_reset(self, patch, scm_inventory, admin_user):
|
||||||
patch(scm_inventory.get_absolute_url(), {'variables': 'hello: world'}, admin_user, expect=200)
|
patch(scm_inventory.get_absolute_url(), {'variables': 'hello: world'}, admin_user, expect=200)
|
||||||
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision == ''
|
|
||||||
|
|
||||||
def test_name_edit_allowed(self, patch, scm_inventory, admin_user):
|
def test_name_edit_allowed(self, patch, scm_inventory, admin_user):
|
||||||
patch(scm_inventory.get_absolute_url(), {'variables': '---', 'name': 'newname'}, admin_user, expect=200)
|
patch(scm_inventory.get_absolute_url(), {'variables': '---', 'name': 'newname'}, admin_user, expect=200)
|
||||||
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision != ''
|
|
||||||
|
|
||||||
def test_host_associations_reset(self, post, scm_inventory, admin_user):
|
def test_host_associations_reset(self, post, scm_inventory, admin_user):
|
||||||
inv_src = scm_inventory.inventory_sources.first()
|
inv_src = scm_inventory.inventory_sources.first()
|
||||||
@@ -560,14 +553,12 @@ class TestControlledBySCM:
|
|||||||
g = inv_src.groups.create(name='fooland', inventory=scm_inventory)
|
g = inv_src.groups.create(name='fooland', inventory=scm_inventory)
|
||||||
post(reverse('api:host_groups_list', kwargs={'pk': h.id}), {'id': g.id}, admin_user, expect=204)
|
post(reverse('api:host_groups_list', kwargs={'pk': h.id}), {'id': g.id}, admin_user, expect=204)
|
||||||
post(reverse('api:group_hosts_list', kwargs={'pk': g.id}), {'id': h.id}, admin_user, expect=204)
|
post(reverse('api:group_hosts_list', kwargs={'pk': g.id}), {'id': h.id}, admin_user, expect=204)
|
||||||
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision == ''
|
|
||||||
|
|
||||||
def test_group_group_associations_reset(self, post, scm_inventory, admin_user):
|
def test_group_group_associations_reset(self, post, scm_inventory, admin_user):
|
||||||
inv_src = scm_inventory.inventory_sources.first()
|
inv_src = scm_inventory.inventory_sources.first()
|
||||||
g1 = inv_src.groups.create(name='barland', inventory=scm_inventory)
|
g1 = inv_src.groups.create(name='barland', inventory=scm_inventory)
|
||||||
g2 = inv_src.groups.create(name='fooland', inventory=scm_inventory)
|
g2 = inv_src.groups.create(name='fooland', inventory=scm_inventory)
|
||||||
post(reverse('api:group_children_list', kwargs={'pk': g1.id}), {'id': g2.id}, admin_user, expect=204)
|
post(reverse('api:group_children_list', kwargs={'pk': g1.id}), {'id': g2.id}, admin_user, expect=204)
|
||||||
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision == ''
|
|
||||||
|
|
||||||
def test_host_group_delete_reset(self, delete, scm_inventory, admin_user):
|
def test_host_group_delete_reset(self, delete, scm_inventory, admin_user):
|
||||||
inv_src = scm_inventory.inventory_sources.first()
|
inv_src = scm_inventory.inventory_sources.first()
|
||||||
@@ -575,7 +566,6 @@ class TestControlledBySCM:
|
|||||||
g = inv_src.groups.create(name='fooland', inventory=scm_inventory)
|
g = inv_src.groups.create(name='fooland', inventory=scm_inventory)
|
||||||
delete(h.get_absolute_url(), admin_user, expect=204)
|
delete(h.get_absolute_url(), admin_user, expect=204)
|
||||||
delete(g.get_absolute_url(), admin_user, expect=204)
|
delete(g.get_absolute_url(), admin_user, expect=204)
|
||||||
assert InventorySource.objects.get(inventory=scm_inventory.pk).scm_last_revision == ''
|
|
||||||
|
|
||||||
def test_remove_scm_inv_src(self, delete, scm_inventory, admin_user):
|
def test_remove_scm_inv_src(self, delete, scm_inventory, admin_user):
|
||||||
inv_src = scm_inventory.inventory_sources.first()
|
inv_src = scm_inventory.inventory_sources.first()
|
||||||
@@ -588,7 +578,6 @@ class TestControlledBySCM:
|
|||||||
{
|
{
|
||||||
'name': 'new inv src',
|
'name': 'new inv src',
|
||||||
'source_project': project.pk,
|
'source_project': project.pk,
|
||||||
'update_on_project_update': False,
|
|
||||||
'source': 'scm',
|
'source': 'scm',
|
||||||
'overwrite_vars': True,
|
'overwrite_vars': True,
|
||||||
'source_vars': 'plugin: a.b.c',
|
'source_vars': 'plugin: a.b.c',
|
||||||
@@ -597,27 +586,6 @@ class TestControlledBySCM:
|
|||||||
expect=201,
|
expect=201,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_adding_inv_src_prohibited(self, post, scm_inventory, project, admin_user):
|
|
||||||
post(
|
|
||||||
reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}),
|
|
||||||
{'name': 'new inv src', 'source_project': project.pk, 'update_on_project_update': True, 'source': 'scm', 'overwrite_vars': True},
|
|
||||||
admin_user,
|
|
||||||
expect=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_two_update_on_project_update_inv_src_prohibited(self, patch, scm_inventory, factory_scm_inventory, project, admin_user):
|
|
||||||
scm_inventory2 = factory_scm_inventory(name="scm_inventory2")
|
|
||||||
res = patch(
|
|
||||||
reverse('api:inventory_source_detail', kwargs={'pk': scm_inventory2.id}),
|
|
||||||
{
|
|
||||||
'update_on_project_update': True,
|
|
||||||
},
|
|
||||||
admin_user,
|
|
||||||
expect=400,
|
|
||||||
)
|
|
||||||
content = json.loads(res.content)
|
|
||||||
assert content['update_on_project_update'] == ["More than one SCM-based inventory source with update on project update " "per-inventory not allowed."]
|
|
||||||
|
|
||||||
def test_adding_inv_src_without_proj_access_prohibited(self, post, project, inventory, rando):
|
def test_adding_inv_src_without_proj_access_prohibited(self, post, project, inventory, rando):
|
||||||
inventory.admin_role.members.add(rando)
|
inventory.admin_role.members.add(rando)
|
||||||
post(
|
post(
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ class TestControllerNode:
|
|||||||
assert 'controller_node' not in r.data
|
assert 'controller_node' not in r.data
|
||||||
|
|
||||||
r = get(reverse('api:inventory_update_detail', kwargs={'pk': inventory_update.pk}), admin_user, expect=200)
|
r = get(reverse('api:inventory_update_detail', kwargs={'pk': inventory_update.pk}), admin_user, expect=200)
|
||||||
assert 'controller_node' not in r.data
|
assert 'controller_node' in r.data
|
||||||
|
|
||||||
r = get(reverse('api:system_job_detail', kwargs={'pk': system_job.pk}), admin_user, expect=200)
|
r = get(reverse('api:system_job_detail', kwargs={'pk': system_job.pk}), admin_user, expect=200)
|
||||||
assert 'controller_node' not in r.data
|
assert 'controller_node' not in r.data
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ def test_complex_schedule(post, admin_user, rrule, expected_result):
|
|||||||
def test_zoneinfo(get, admin_user):
|
def test_zoneinfo(get, admin_user):
|
||||||
url = reverse('api:schedule_zoneinfo')
|
url = reverse('api:schedule_zoneinfo')
|
||||||
r = get(url, admin_user, expect=200)
|
r = get(url, admin_user, expect=200)
|
||||||
assert {'name': 'America/New_York'} in r.data
|
assert 'America/New_York' in r.data['zones']
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -347,9 +347,7 @@ def scm_inventory_source(inventory, project):
|
|||||||
source_project=project,
|
source_project=project,
|
||||||
source='scm',
|
source='scm',
|
||||||
source_path='inventory_file',
|
source_path='inventory_file',
|
||||||
update_on_project_update=True,
|
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
scm_last_revision=project.scm_revision,
|
|
||||||
)
|
)
|
||||||
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
||||||
inv_src.save()
|
inv_src.save()
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models import Host, Inventory, InventorySource, InventoryUpdate, CredentialType, Credential, Job
|
from awx.main.models import Host, Inventory, InventorySource, InventoryUpdate, CredentialType, Credential, Job
|
||||||
from awx.main.constants import CLOUD_PROVIDERS
|
from awx.main.constants import CLOUD_PROVIDERS
|
||||||
@@ -123,19 +121,6 @@ class TestActiveCount:
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestSCMUpdateFeatures:
|
class TestSCMUpdateFeatures:
|
||||||
def test_automatic_project_update_on_create(self, inventory, project):
|
|
||||||
inv_src = InventorySource(source_project=project, source_path='inventory_file', inventory=inventory, update_on_project_update=True, source='scm')
|
|
||||||
with mock.patch.object(inv_src, 'update') as mck_update:
|
|
||||||
inv_src.save()
|
|
||||||
mck_update.assert_called_once_with()
|
|
||||||
|
|
||||||
def test_reset_scm_revision(self, scm_inventory_source):
|
|
||||||
starting_rev = scm_inventory_source.scm_last_revision
|
|
||||||
assert starting_rev != ''
|
|
||||||
scm_inventory_source.source_path = '/newfolder/newfile.ini'
|
|
||||||
scm_inventory_source.save()
|
|
||||||
assert scm_inventory_source.scm_last_revision == ''
|
|
||||||
|
|
||||||
def test_source_location(self, scm_inventory_source):
|
def test_source_location(self, scm_inventory_source):
|
||||||
# Combines project directory with the inventory file specified
|
# Combines project directory with the inventory file specified
|
||||||
inventory_update = InventoryUpdate(inventory_source=scm_inventory_source, source_path=scm_inventory_source.source_path)
|
inventory_update = InventoryUpdate(inventory_source=scm_inventory_source, source_path=scm_inventory_source.source_path)
|
||||||
@@ -167,22 +152,6 @@ class TestRelatedJobs:
|
|||||||
assert job.id in [jerb.id for jerb in group._get_related_jobs()]
|
assert job.id in [jerb.id for jerb in group._get_related_jobs()]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestSCMClean:
|
|
||||||
def test_clean_update_on_project_update_multiple(self, inventory):
|
|
||||||
inv_src1 = InventorySource(inventory=inventory, update_on_project_update=True, source='scm')
|
|
||||||
inv_src1.clean_update_on_project_update()
|
|
||||||
inv_src1.save()
|
|
||||||
|
|
||||||
inv_src1.source_vars = '---\nhello: world'
|
|
||||||
inv_src1.clean_update_on_project_update()
|
|
||||||
|
|
||||||
inv_src2 = InventorySource(inventory=inventory, update_on_project_update=True, source='scm')
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
inv_src2.clean_update_on_project_update()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestInventorySourceInjectors:
|
class TestInventorySourceInjectors:
|
||||||
def test_extra_credentials(self, project, credential):
|
def test_extra_credentials(self, project, credential):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from awx.api.views import WorkflowJobTemplateNodeSuccessNodesList
|
|||||||
# Django
|
# Django
|
||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.timezone import now
|
||||||
|
|
||||||
|
|
||||||
class TestWorkflowDAGFunctional(TransactionTestCase):
|
class TestWorkflowDAGFunctional(TransactionTestCase):
|
||||||
@@ -381,3 +382,38 @@ def test_workflow_ancestors_recursion_prevention(organization):
|
|||||||
WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=wfjt, job=wfj) # well, this is a problem
|
WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=wfjt, job=wfj) # well, this is a problem
|
||||||
# mostly, we just care that this assertion finishes in finite time
|
# mostly, we just care that this assertion finishes in finite time
|
||||||
assert wfj.get_ancestor_workflows() == []
|
assert wfj.get_ancestor_workflows() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestCombinedArtifacts:
|
||||||
|
@pytest.fixture
|
||||||
|
def wfj_artifacts(self, job_template, organization):
|
||||||
|
wfjt = WorkflowJobTemplate.objects.create(organization=organization, name='has_artifacts')
|
||||||
|
wfj = WorkflowJob.objects.create(workflow_job_template=wfjt, launch_type='workflow')
|
||||||
|
job = job_template.create_unified_job(_eager_fields=dict(artifacts={'foooo': 'bar'}, status='successful', finished=now()))
|
||||||
|
WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=job_template, job=job)
|
||||||
|
return wfj
|
||||||
|
|
||||||
|
def test_multiple_types(self, project, wfj_artifacts):
|
||||||
|
project_update = project.create_unified_job()
|
||||||
|
WorkflowJobNode.objects.create(workflow_job=wfj_artifacts, unified_job_template=project, job=project_update)
|
||||||
|
|
||||||
|
assert wfj_artifacts.get_effective_artifacts() == {'foooo': 'bar'}
|
||||||
|
|
||||||
|
def test_precedence_based_on_time(self, wfj_artifacts, job_template):
|
||||||
|
later_job = job_template.create_unified_job(
|
||||||
|
_eager_fields=dict(artifacts={'foooo': 'zoo'}, status='successful', finished=now()) # finished later, should win
|
||||||
|
)
|
||||||
|
WorkflowJobNode.objects.create(workflow_job=wfj_artifacts, unified_job_template=job_template, job=later_job)
|
||||||
|
|
||||||
|
assert wfj_artifacts.get_effective_artifacts() == {'foooo': 'zoo'}
|
||||||
|
|
||||||
|
def test_bad_data_with_artifacts(self, organization):
|
||||||
|
# This is toxic database data, this tests that it doesn't create an infinite loop
|
||||||
|
wfjt = WorkflowJobTemplate.objects.create(organization=organization, name='child')
|
||||||
|
wfj = WorkflowJob.objects.create(workflow_job_template=wfjt, launch_type='workflow')
|
||||||
|
WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=wfjt, job=wfj)
|
||||||
|
job = Job.objects.create(artifacts={'foo': 'bar'}, status='successful')
|
||||||
|
WorkflowJobNode.objects.create(workflow_job=wfj, job=job)
|
||||||
|
# mostly, we just care that this assertion finishes in finite time
|
||||||
|
assert wfj.get_effective_artifacts() == {'foo': 'bar'}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from awx.main.tasks.jobs import RunProjectUpdate, RunInventoryUpdate
|
|
||||||
from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files
|
from awx.main.tasks.system import execution_node_health_check, _cleanup_images_and_files
|
||||||
from awx.main.models import ProjectUpdate, InventoryUpdate, InventorySource, Instance, Job
|
from awx.main.models import Instance, Job
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -27,63 +26,6 @@ def test_no_worker_info_on_AWX_nodes(node_type):
|
|||||||
execution_node_health_check(hostname)
|
execution_node_health_check(hostname)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestDependentInventoryUpdate:
|
|
||||||
def test_dependent_inventory_updates_is_called(self, scm_inventory_source, scm_revision_file, mock_me):
|
|
||||||
task = RunProjectUpdate()
|
|
||||||
task.revision_path = scm_revision_file
|
|
||||||
proj_update = scm_inventory_source.source_project.create_project_update()
|
|
||||||
with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck:
|
|
||||||
with mock.patch.object(RunProjectUpdate, 'release_lock'):
|
|
||||||
task.post_run_hook(proj_update, 'successful')
|
|
||||||
inv_update_mck.assert_called_once_with(proj_update, mock.ANY)
|
|
||||||
|
|
||||||
def test_no_unwanted_dependent_inventory_updates(self, project, scm_revision_file, mock_me):
|
|
||||||
task = RunProjectUpdate()
|
|
||||||
task.revision_path = scm_revision_file
|
|
||||||
proj_update = project.create_project_update()
|
|
||||||
with mock.patch.object(RunProjectUpdate, '_update_dependent_inventories') as inv_update_mck:
|
|
||||||
with mock.patch.object(RunProjectUpdate, 'release_lock'):
|
|
||||||
task.post_run_hook(proj_update, 'successful')
|
|
||||||
assert not inv_update_mck.called
|
|
||||||
|
|
||||||
def test_dependent_inventory_updates(self, scm_inventory_source, default_instance_group, mock_me):
|
|
||||||
task = RunProjectUpdate()
|
|
||||||
scm_inventory_source.scm_last_revision = ''
|
|
||||||
proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project)
|
|
||||||
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
|
|
||||||
with mock.patch('awx.main.tasks.jobs.create_partition'):
|
|
||||||
task._update_dependent_inventories(proj_update, [scm_inventory_source])
|
|
||||||
assert InventoryUpdate.objects.count() == 1
|
|
||||||
inv_update = InventoryUpdate.objects.first()
|
|
||||||
iu_run_mock.assert_called_once_with(inv_update.id)
|
|
||||||
assert inv_update.source_project_update_id == proj_update.pk
|
|
||||||
|
|
||||||
def test_dependent_inventory_project_cancel(self, project, inventory, default_instance_group, mock_me):
|
|
||||||
"""
|
|
||||||
Test that dependent inventory updates exhibit good behavior on cancel
|
|
||||||
of the source project update
|
|
||||||
"""
|
|
||||||
task = RunProjectUpdate()
|
|
||||||
proj_update = ProjectUpdate.objects.create(project=project)
|
|
||||||
|
|
||||||
kwargs = dict(source_project=project, source='scm', source_path='inventory_file', update_on_project_update=True, inventory=inventory)
|
|
||||||
|
|
||||||
is1 = InventorySource.objects.create(name="test-scm-inv", **kwargs)
|
|
||||||
is2 = InventorySource.objects.create(name="test-scm-inv2", **kwargs)
|
|
||||||
|
|
||||||
def user_cancels_project(pk):
|
|
||||||
ProjectUpdate.objects.all().update(cancel_flag=True)
|
|
||||||
|
|
||||||
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
|
|
||||||
with mock.patch('awx.main.tasks.jobs.create_partition'):
|
|
||||||
iu_run_mock.side_effect = user_cancels_project
|
|
||||||
task._update_dependent_inventories(proj_update, [is1, is2])
|
|
||||||
# Verify that it bails after 1st update, detecting a cancel
|
|
||||||
assert is2.inventory_updates.count() == 0
|
|
||||||
iu_run_mock.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_job_folder(request):
|
def mock_job_folder(request):
|
||||||
pdd_path = tempfile.mkdtemp(prefix='awx_123_')
|
pdd_path = tempfile.mkdtemp(prefix='awx_123_')
|
||||||
|
|||||||
@@ -69,21 +69,21 @@ class TestJobTemplateLabelList:
|
|||||||
|
|
||||||
class TestInventoryInventorySourcesUpdate:
|
class TestInventoryInventorySourcesUpdate:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"can_update, can_access, is_source, is_up_on_proj, expected",
|
"can_update, can_access, is_source, expected",
|
||||||
[
|
[
|
||||||
(True, True, "ec2", False, [{'status': 'started', 'inventory_update': 1, 'inventory_source': 1}]),
|
(True, True, "ec2", [{'status': 'started', 'inventory_update': 1, 'inventory_source': 1}]),
|
||||||
(False, True, "gce", False, [{'status': 'Could not start because `can_update` returned False', 'inventory_source': 1}]),
|
(False, True, "gce", [{'status': 'Could not start because `can_update` returned False', 'inventory_source': 1}]),
|
||||||
(True, False, "scm", True, [{'status': 'started', 'inventory_update': 1, 'inventory_source': 1}]),
|
(True, False, "scm", [{'status': 'started', 'inventory_update': 1, 'inventory_source': 1}]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_post(self, mocker, can_update, can_access, is_source, is_up_on_proj, expected):
|
def test_post(self, mocker, can_update, can_access, is_source, expected):
|
||||||
class InventoryUpdate:
|
class InventoryUpdate:
|
||||||
id = 1
|
id = 1
|
||||||
|
|
||||||
class Project:
|
class Project:
|
||||||
name = 'project'
|
name = 'project'
|
||||||
|
|
||||||
InventorySource = namedtuple('InventorySource', ['source', 'update_on_project_update', 'pk', 'can_update', 'update', 'source_project'])
|
InventorySource = namedtuple('InventorySource', ['source', 'pk', 'can_update', 'update', 'source_project'])
|
||||||
|
|
||||||
class InventorySources(object):
|
class InventorySources(object):
|
||||||
def all(self):
|
def all(self):
|
||||||
@@ -92,7 +92,6 @@ class TestInventoryInventorySourcesUpdate:
|
|||||||
pk=1,
|
pk=1,
|
||||||
source=is_source,
|
source=is_source,
|
||||||
source_project=Project,
|
source_project=Project,
|
||||||
update_on_project_update=is_up_on_proj,
|
|
||||||
can_update=can_update,
|
can_update=can_update,
|
||||||
update=lambda: InventoryUpdate,
|
update=lambda: InventoryUpdate,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from awx.main.models import (
|
from awx.main.models import (
|
||||||
UnifiedJob,
|
|
||||||
InventoryUpdate,
|
InventoryUpdate,
|
||||||
InventorySource,
|
InventorySource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_cancel(mocker):
|
|
||||||
with mock.patch.object(UnifiedJob, 'cancel', return_value=True) as parent_cancel:
|
|
||||||
iu = InventoryUpdate()
|
|
||||||
|
|
||||||
iu.save = mocker.MagicMock()
|
|
||||||
build_job_explanation_mock = mocker.MagicMock()
|
|
||||||
iu._build_job_explanation = mocker.MagicMock(return_value=build_job_explanation_mock)
|
|
||||||
|
|
||||||
iu.cancel()
|
|
||||||
|
|
||||||
parent_cancel.assert_called_with(is_chain=False, job_explanation=None)
|
|
||||||
|
|
||||||
|
|
||||||
def test__build_job_explanation():
|
def test__build_job_explanation():
|
||||||
iu = InventoryUpdate(id=3, name='I_am_an_Inventory_Update')
|
iu = InventoryUpdate(id=3, name='I_am_an_Inventory_Update')
|
||||||
|
|
||||||
@@ -53,9 +38,3 @@ class TestControlledBySCM:
|
|||||||
|
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
inv_src.clean_source_path()
|
inv_src.clean_source_path()
|
||||||
|
|
||||||
def test_clean_update_on_launch_update_on_project_update(self):
|
|
||||||
inv_src = InventorySource(update_on_project_update=True, update_on_launch=True, source='scm')
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
inv_src.clean_update_on_launch()
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from awx.main.tasks.callback import RunnerCallback
|
from awx.main.tasks.callback import RunnerCallback
|
||||||
from awx.main.constants import ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE
|
from awx.main.constants import ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
def test_delay_update(mock_me):
|
def test_delay_update(mock_me):
|
||||||
|
|||||||
50
awx/main/tests/unit/tasks/test_signals.py
Normal file
50
awx/main/tests/unit/tasks/test_signals.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import signal
|
||||||
|
|
||||||
|
from awx.main.tasks.signals import signal_state, signal_callback, with_signal_handling
|
||||||
|
|
||||||
|
|
||||||
|
def test_outer_inner_signal_handling():
|
||||||
|
"""
|
||||||
|
Even if the flag is set in the outer context, its value should persist in the inner context
|
||||||
|
"""
|
||||||
|
|
||||||
|
@with_signal_handling
|
||||||
|
def f2():
|
||||||
|
assert signal_callback()
|
||||||
|
|
||||||
|
@with_signal_handling
|
||||||
|
def f1():
|
||||||
|
assert signal_callback() is False
|
||||||
|
signal_state.set_flag()
|
||||||
|
assert signal_callback()
|
||||||
|
f2()
|
||||||
|
|
||||||
|
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
|
assert signal_callback() is False
|
||||||
|
f1()
|
||||||
|
assert signal_callback() is False
|
||||||
|
assert signal.getsignal(signal.SIGTERM) is original_sigterm
|
||||||
|
|
||||||
|
|
||||||
|
def test_inner_outer_signal_handling():
|
||||||
|
"""
|
||||||
|
Even if the flag is set in the inner context, its value should persist in the outer context
|
||||||
|
"""
|
||||||
|
|
||||||
|
@with_signal_handling
|
||||||
|
def f2():
|
||||||
|
assert signal_callback() is False
|
||||||
|
signal_state.set_flag()
|
||||||
|
assert signal_callback()
|
||||||
|
|
||||||
|
@with_signal_handling
|
||||||
|
def f1():
|
||||||
|
assert signal_callback() is False
|
||||||
|
f2()
|
||||||
|
assert signal_callback()
|
||||||
|
|
||||||
|
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
|
assert signal_callback() is False
|
||||||
|
f1()
|
||||||
|
assert signal_callback() is False
|
||||||
|
assert signal.getsignal(signal.SIGTERM) is original_sigterm
|
||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -36,12 +37,23 @@ from awx.main.models.credential import HIDDEN_PASSWORD, ManagedCredentialType
|
|||||||
from awx.main.tasks import jobs, system
|
from awx.main.tasks import jobs, system
|
||||||
from awx.main.utils import encrypt_field, encrypt_value
|
from awx.main.utils import encrypt_field, encrypt_value
|
||||||
from awx.main.utils.safe_yaml import SafeLoader
|
from awx.main.utils.safe_yaml import SafeLoader
|
||||||
from awx.main.utils.execution_environments import CONTAINER_ROOT, to_host_path
|
from awx.main.utils.execution_environments import CONTAINER_ROOT
|
||||||
|
|
||||||
from awx.main.utils.licensing import Licenser
|
from awx.main.utils.licensing import Licenser
|
||||||
from awx.main.constants import JOB_VARIABLE_PREFIXES
|
from awx.main.constants import JOB_VARIABLE_PREFIXES
|
||||||
|
|
||||||
|
|
||||||
|
def to_host_path(path, private_data_dir):
|
||||||
|
"""Given a path inside of the EE container, this gives the absolute path
|
||||||
|
on the host machine within the private_data_dir
|
||||||
|
"""
|
||||||
|
if not os.path.isabs(private_data_dir):
|
||||||
|
raise RuntimeError('The private_data_dir path must be absolute')
|
||||||
|
if CONTAINER_ROOT != path and Path(CONTAINER_ROOT) not in Path(path).resolve().parents:
|
||||||
|
raise RuntimeError(f'Cannot convert path {path} unless it is a subdir of {CONTAINER_ROOT}')
|
||||||
|
return path.replace(CONTAINER_ROOT, private_data_dir, 1)
|
||||||
|
|
||||||
|
|
||||||
class TestJobExecution(object):
|
class TestJobExecution(object):
|
||||||
EXAMPLE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nxyz==\n-----END PRIVATE KEY-----'
|
EXAMPLE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nxyz==\n-----END PRIVATE KEY-----'
|
||||||
|
|
||||||
@@ -910,7 +922,8 @@ class TestJobCredentials(TestJobExecution):
|
|||||||
assert env['AWS_SECURITY_TOKEN'] == 'token'
|
assert env['AWS_SECURITY_TOKEN'] == 'token'
|
||||||
assert safe_env['AWS_SECRET_ACCESS_KEY'] == HIDDEN_PASSWORD
|
assert safe_env['AWS_SECRET_ACCESS_KEY'] == HIDDEN_PASSWORD
|
||||||
|
|
||||||
def test_gce_credentials(self, private_data_dir, job, mock_me):
|
@pytest.mark.parametrize("cred_env_var", ['GCE_CREDENTIALS_FILE_PATH', 'GOOGLE_APPLICATION_CREDENTIALS'])
|
||||||
|
def test_gce_credentials(self, cred_env_var, private_data_dir, job, mock_me):
|
||||||
gce = CredentialType.defaults['gce']()
|
gce = CredentialType.defaults['gce']()
|
||||||
credential = Credential(pk=1, credential_type=gce, inputs={'username': 'bob', 'project': 'some-project', 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY})
|
credential = Credential(pk=1, credential_type=gce, inputs={'username': 'bob', 'project': 'some-project', 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY})
|
||||||
credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data')
|
credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data')
|
||||||
@@ -919,7 +932,7 @@ class TestJobCredentials(TestJobExecution):
|
|||||||
env = {}
|
env = {}
|
||||||
safe_env = {}
|
safe_env = {}
|
||||||
credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir)
|
credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir)
|
||||||
runner_path = env['GCE_CREDENTIALS_FILE_PATH']
|
runner_path = env[cred_env_var]
|
||||||
local_path = to_host_path(runner_path, private_data_dir)
|
local_path = to_host_path(runner_path, private_data_dir)
|
||||||
json_data = json.load(open(local_path, 'rb'))
|
json_data = json.load(open(local_path, 'rb'))
|
||||||
assert json_data['type'] == 'service_account'
|
assert json_data['type'] == 'service_account'
|
||||||
@@ -1304,6 +1317,7 @@ class TestJobCredentials(TestJobExecution):
|
|||||||
assert env['AZURE_AD_USER'] == 'bob'
|
assert env['AZURE_AD_USER'] == 'bob'
|
||||||
assert env['AZURE_PASSWORD'] == 'secret'
|
assert env['AZURE_PASSWORD'] == 'secret'
|
||||||
|
|
||||||
|
# Because this is testing a mix of multiple cloud creds, we are not going to test the GOOGLE_APPLICATION_CREDENTIALS here
|
||||||
path = to_host_path(env['GCE_CREDENTIALS_FILE_PATH'], private_data_dir)
|
path = to_host_path(env['GCE_CREDENTIALS_FILE_PATH'], private_data_dir)
|
||||||
json_data = json.load(open(path, 'rb'))
|
json_data = json.load(open(path, 'rb'))
|
||||||
assert json_data['type'] == 'service_account'
|
assert json_data['type'] == 'service_account'
|
||||||
@@ -1633,7 +1647,8 @@ class TestInventoryUpdateCredentials(TestJobExecution):
|
|||||||
|
|
||||||
assert safe_env['AZURE_PASSWORD'] == HIDDEN_PASSWORD
|
assert safe_env['AZURE_PASSWORD'] == HIDDEN_PASSWORD
|
||||||
|
|
||||||
def test_gce_source(self, inventory_update, private_data_dir, mocker, mock_me):
|
@pytest.mark.parametrize("cred_env_var", ['GCE_CREDENTIALS_FILE_PATH', 'GOOGLE_APPLICATION_CREDENTIALS'])
|
||||||
|
def test_gce_source(self, cred_env_var, inventory_update, private_data_dir, mocker, mock_me):
|
||||||
task = jobs.RunInventoryUpdate()
|
task = jobs.RunInventoryUpdate()
|
||||||
task.instance = inventory_update
|
task.instance = inventory_update
|
||||||
gce = CredentialType.defaults['gce']()
|
gce = CredentialType.defaults['gce']()
|
||||||
@@ -1657,7 +1672,7 @@ class TestInventoryUpdateCredentials(TestJobExecution):
|
|||||||
credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir)
|
credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir)
|
||||||
|
|
||||||
assert env['GCE_ZONE'] == expected_gce_zone
|
assert env['GCE_ZONE'] == expected_gce_zone
|
||||||
json_data = json.load(open(env['GCE_CREDENTIALS_FILE_PATH'], 'rb'))
|
json_data = json.load(open(env[cred_env_var], 'rb'))
|
||||||
assert json_data['type'] == 'service_account'
|
assert json_data['type'] == 'service_account'
|
||||||
assert json_data['private_key'] == self.EXAMPLE_PRIVATE_KEY
|
assert json_data['private_key'] == self.EXAMPLE_PRIVATE_KEY
|
||||||
assert json_data['client_email'] == 'bob'
|
assert json_data['client_email'] == 'bob'
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from awx.main.utils.execution_environments import to_container_path, to_host_path
|
from awx.main.utils.execution_environments import to_container_path
|
||||||
|
|
||||||
|
|
||||||
private_data_dir = '/tmp/pdd_iso/awx_xxx'
|
private_data_dir = '/tmp/pdd_iso/awx_xxx'
|
||||||
@@ -10,26 +14,33 @@ private_data_dir = '/tmp/pdd_iso/awx_xxx'
|
|||||||
'container_path,host_path',
|
'container_path,host_path',
|
||||||
[
|
[
|
||||||
('/runner', private_data_dir),
|
('/runner', private_data_dir),
|
||||||
('/runner/foo', '{0}/foo'.format(private_data_dir)),
|
('/runner/foo', f'{private_data_dir}/foo'),
|
||||||
('/runner/foo/bar', '{0}/foo/bar'.format(private_data_dir)),
|
('/runner', f'{private_data_dir}/foobar/..'), # private_data_dir path needs to be resolved
|
||||||
('/runner{0}'.format(private_data_dir), '{0}{0}'.format(private_data_dir)),
|
('/runner/bar', f'{private_data_dir}/bar/foo/..'),
|
||||||
|
('/runner/foo/bar', f'{private_data_dir}/foo/bar'),
|
||||||
|
(f'/runner{private_data_dir}', f'{private_data_dir}{private_data_dir}'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_switch_paths(container_path, host_path):
|
def test_switch_paths(container_path, host_path):
|
||||||
assert to_container_path(host_path, private_data_dir) == container_path
|
assert to_container_path(host_path, private_data_dir) == container_path
|
||||||
assert to_host_path(container_path, private_data_dir) == host_path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_symlink_isolation_dir(request):
|
||||||
'container_path',
|
rand_str = str(uuid4())[:8]
|
||||||
[
|
dst_path = f'/tmp/ee_{rand_str}_symlink_dst'
|
||||||
('/foobar'),
|
src_path = f'/tmp/ee_{rand_str}_symlink_src'
|
||||||
('/runner/..'),
|
|
||||||
],
|
def remove_folders():
|
||||||
)
|
os.unlink(dst_path)
|
||||||
def test_invalid_container_path(container_path):
|
shutil.rmtree(src_path)
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
to_host_path(container_path, private_data_dir)
|
request.addfinalizer(remove_folders)
|
||||||
|
os.mkdir(src_path)
|
||||||
|
os.symlink(src_path, dst_path)
|
||||||
|
|
||||||
|
pdd = f'{dst_path}/awx_xxx'
|
||||||
|
|
||||||
|
assert to_container_path(f'{pdd}/env/tmp1234', pdd) == '/runner/env/tmp1234'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ logger = logging.getLogger('awx.main.utils.ansible')
|
|||||||
__all__ = ['skip_directory', 'could_be_playbook', 'could_be_inventory']
|
__all__ = ['skip_directory', 'could_be_playbook', 'could_be_inventory']
|
||||||
|
|
||||||
|
|
||||||
valid_playbook_re = re.compile(r'^\s*?-?\s*?(?:hosts|include|import_playbook):\s*?.*?$')
|
valid_playbook_re = re.compile(r'^\s*?-?\s*?(?:hosts|(ansible\.builtin\.)?include|(ansible\.builtin\.)?import_playbook):\s*?.*?$')
|
||||||
valid_inventory_re = re.compile(r'^[a-zA-Z0-9_.=\[\]]')
|
valid_inventory_re = re.compile(r'^[a-zA-Z0-9_.=\[\]]')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,17 +58,9 @@ def to_container_path(path, private_data_dir):
|
|||||||
"""
|
"""
|
||||||
if not os.path.isabs(private_data_dir):
|
if not os.path.isabs(private_data_dir):
|
||||||
raise RuntimeError('The private_data_dir path must be absolute')
|
raise RuntimeError('The private_data_dir path must be absolute')
|
||||||
if private_data_dir != path and Path(private_data_dir) not in Path(path).resolve().parents:
|
# due to how tempfile.mkstemp works, we are probably passed a resolved path, but unresolved private_data_dir
|
||||||
raise RuntimeError(f'Cannot convert path {path} unless it is a subdir of {private_data_dir}')
|
resolved_path = Path(path).resolve()
|
||||||
return path.replace(private_data_dir, CONTAINER_ROOT, 1)
|
resolved_pdd = Path(private_data_dir).resolve()
|
||||||
|
if resolved_pdd != resolved_path and resolved_pdd not in resolved_path.parents:
|
||||||
|
raise RuntimeError(f'Cannot convert path {resolved_path} unless it is a subdir of {resolved_pdd}')
|
||||||
def to_host_path(path, private_data_dir):
|
return str(resolved_path).replace(str(resolved_pdd), CONTAINER_ROOT, 1)
|
||||||
"""Given a path inside of the EE container, this gives the absolute path
|
|
||||||
on the host machine within the private_data_dir
|
|
||||||
"""
|
|
||||||
if not os.path.isabs(private_data_dir):
|
|
||||||
raise RuntimeError('The private_data_dir path must be absolute')
|
|
||||||
if CONTAINER_ROOT != path and Path(CONTAINER_ROOT) not in Path(path).resolve().parents:
|
|
||||||
raise RuntimeError(f'Cannot convert path {path} unless it is a subdir of {CONTAINER_ROOT}')
|
|
||||||
return path.replace(CONTAINER_ROOT, private_data_dir, 1)
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from django.db import transaction, DatabaseError, InterfaceError
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from awx.main.tasks.signals import signal_callback
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.tasks.utils')
|
logger = logging.getLogger('awx.main.tasks.utils')
|
||||||
|
|
||||||
@@ -37,7 +39,10 @@ def update_model(model, pk, _attempt=0, _max_attempts=5, select_for_update=False
|
|||||||
# Attempt to retry the update, assuming we haven't already
|
# Attempt to retry the update, assuming we haven't already
|
||||||
# tried too many times.
|
# tried too many times.
|
||||||
if _attempt < _max_attempts:
|
if _attempt < _max_attempts:
|
||||||
time.sleep(5)
|
for i in range(5):
|
||||||
|
time.sleep(1)
|
||||||
|
if signal_callback():
|
||||||
|
raise RuntimeError(f'Could not fetch {pk} because of receiving abort signal')
|
||||||
return update_model(model, pk, _attempt=_attempt + 1, _max_attempts=_max_attempts, **updates)
|
return update_model(model, pk, _attempt=_attempt + 1, _max_attempts=_max_attempts, **updates)
|
||||||
else:
|
else:
|
||||||
logger.error('Failed to update %s after %d retries.', model._meta.object_name, _attempt)
|
logger.error('Failed to update %s after %d retries.', model._meta.object_name, _attempt)
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ SUBSYSTEM_METRICS_INTERVAL_SEND_METRICS = 3
|
|||||||
# Interval in seconds for saving local metrics to redis
|
# Interval in seconds for saving local metrics to redis
|
||||||
SUBSYSTEM_METRICS_INTERVAL_SAVE_TO_REDIS = 2
|
SUBSYSTEM_METRICS_INTERVAL_SAVE_TO_REDIS = 2
|
||||||
|
|
||||||
|
# Record task manager metrics at the following interval in seconds
|
||||||
|
# If using Prometheus, it is recommended to be => the Prometheus scrape interval
|
||||||
|
SUBSYSTEM_METRICS_TASK_MANAGER_RECORD_INTERVAL = 15
|
||||||
|
|
||||||
# The maximum allowed jobs to start on a given task manager cycle
|
# The maximum allowed jobs to start on a given task manager cycle
|
||||||
START_TASK_LIMIT = 100
|
START_TASK_LIMIT = 100
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"babelOptions": {
|
"babelOptions": {
|
||||||
"presets": ["@babel/preset-react"]
|
"presets": ["@babel/preset-react"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": ["react-hooks", "jsx-a11y", "i18next", "@babel"],
|
"plugins": ["react-hooks", "jsx-a11y", "i18next", "@babel"],
|
||||||
"extends": [
|
"extends": [
|
||||||
@@ -96,9 +96,18 @@
|
|||||||
"modifier",
|
"modifier",
|
||||||
"data-cy",
|
"data-cy",
|
||||||
"fieldName",
|
"fieldName",
|
||||||
"splitButtonVariant"
|
"splitButtonVariant",
|
||||||
|
"pageKey"
|
||||||
|
],
|
||||||
|
"ignore": [
|
||||||
|
"Ansible",
|
||||||
|
"Tower",
|
||||||
|
"JSON",
|
||||||
|
"YAML",
|
||||||
|
"lg",
|
||||||
|
"hh:mm AM/PM",
|
||||||
|
"Twilio"
|
||||||
],
|
],
|
||||||
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg", "hh:mm AM/PM", "Twilio"],
|
|
||||||
"ignoreComponent": [
|
"ignoreComponent": [
|
||||||
"AboutModal",
|
"AboutModal",
|
||||||
"code",
|
"code",
|
||||||
@@ -139,7 +148,7 @@
|
|||||||
"object-curly-newline": "off",
|
"object-curly-newline": "off",
|
||||||
"no-trailing-spaces": ["error"],
|
"no-trailing-spaces": ["error"],
|
||||||
"no-unused-expressions": ["error", { "allowShortCircuit": true }],
|
"no-unused-expressions": ["error", { "allowShortCircuit": true }],
|
||||||
"react/jsx-props-no-spreading":["off"],
|
"react/jsx-props-no-spreading": ["off"],
|
||||||
"react/prefer-stateless-function": "off",
|
"react/prefer-stateless-function": "off",
|
||||||
"react/prop-types": "off",
|
"react/prop-types": "off",
|
||||||
"react/sort-comp": ["error", {}],
|
"react/sort-comp": ["error", {}],
|
||||||
|
|||||||
391
awx/ui/SEARCH.md
391
awx/ui/SEARCH.md
@@ -2,26 +2,27 @@
|
|||||||
|
|
||||||
## UX Considerations
|
## UX Considerations
|
||||||
|
|
||||||
Historically, the code that powers search in the AngularJS version of the AWX UI is very complex and prone to bugs. In order to reduce that complexity, we've made some UX decisions to help make the code easier to maintain.
|
Historically, the code that powers search in the AngularJS version of the AWX UI is very complex and prone to bugs. In order to reduce that complexity, we've made some UX decisions to help make the code easier to maintain.
|
||||||
|
|
||||||
**ALL query params namespaced and in url bar**
|
**ALL query params namespaced and in url bar**
|
||||||
|
|
||||||
This includes lists that aren't necessarily hyperlinked, like lookup lists. The reason behind this is so we can treat the url bar as the source of truth for queries always. Any params that have both a key AND value that is in the defaultParams section of the qs config are stripped out of the search string (see "Encoding for UI vs. API" for more info on this point)
|
This includes lists that aren't necessarily hyperlinked, like lookup lists. The reason behind this is so we can treat the url bar as the source of truth for queries always. Any params that have both a key AND value that is in the defaultParams section of the qs config are stripped out of the search string (see "Encoding for UI vs. API" for more info on this point)
|
||||||
|
|
||||||
**Django fuzzy search (`?search=`) is not accessible outside of "advanced search"**
|
**Django fuzzy search (`?search=`) is not accessible outside of "advanced search"**
|
||||||
|
|
||||||
In current smart search typing a term with no key utilizes `?search=` i.e. for "foo" tag, `?search=foo` is given. `?search=` looks on a static list of field name "guesses" (such as name, description, etc.), as well as specific fields as defined for each endpoint (for example, the events endpoint looks for a "stdout" field as well). Due to the fact a key will always be present on the left-hand of simple search, it doesn't make sense to use `?search=` as the default.
|
In current smart search typing a term with no key utilizes `?search=` i.e. for "foo" tag, `?search=foo` is given. `?search=` looks on a static list of field name "guesses" (such as name, description, etc.), as well as specific fields as defined for each endpoint (for example, the events endpoint looks for a "stdout" field as well). Due to the fact a key will always be present on the left-hand of simple search, it doesn't make sense to use `?search=` as the default.
|
||||||
|
|
||||||
|
We may allow passing of `?search=` through our future advanced search interface. Some details that were gathered in planning phases about `?search=` that might be helpful in the future:
|
||||||
|
|
||||||
We may allow passing of `?search=` through our future advanced search interface. Some details that were gathered in planning phases about `?search=` that might be helpful in the future:
|
|
||||||
- `?search=` tags are OR'd together (union is returned).
|
- `?search=` tags are OR'd together (union is returned).
|
||||||
- `?search=foo&name=bar` returns items that have a name field of bar (not case insensitive) AND some text field with foo on it
|
- `?search=foo&name=bar` returns items that have a name field of bar (not case insensitive) AND some text field with foo on it
|
||||||
- `?search=foo&search=bar&name=baz` returns (foo in name OR foo in description OR ...) AND (bar in name OR bar in description OR ...) AND (baz in name)
|
- `?search=foo&search=bar&name=baz` returns (foo in name OR foo in description OR ...) AND (bar in name OR bar in description OR ...) AND (baz in name)
|
||||||
- similarly `?related__search=` looks on the static list of "guesses" for models related to the endpoint. The specific fields are not "searched" for `?related__search=`.
|
- similarly `?related__search=` looks on the static list of "guesses" for models related to the endpoint. The specific fields are not "searched" for `?related__search=`.
|
||||||
- `?related__search=` not currently used in awx ui
|
- `?related__search=` not currently used in awx ui
|
||||||
|
|
||||||
**A note on clicking a tag to putting it back into the search bar**
|
**A note on clicking a tag to putting it back into the search bar**
|
||||||
|
|
||||||
This was brought up as a nice to have when we were discussing our initial implementation of search in the new application. Since there isn't a way we would be able to know if the user created the tag from the simple or advanced search interface, we wouldn't know where to put it back. This breaks our idea of using the query params as the exclusive source of truth, so we've decided against implementing it for now.
|
This was brought up as a nice to have when we were discussing our initial implementation of search in the new application. Since there isn't a way we would be able to know if the user created the tag from the simple or advanced search interface, we wouldn't know where to put it back. This breaks our idea of using the query params as the exclusive source of truth, so we've decided against implementing it for now.
|
||||||
|
|
||||||
## Tasklist
|
## Tasklist
|
||||||
|
|
||||||
@@ -50,171 +51,197 @@ This was brought up as a nice to have when we were discussing our initial implem
|
|||||||
- DONE remove button for search tags of duplicate keys are broken, fix that
|
- DONE remove button for search tags of duplicate keys are broken, fix that
|
||||||
|
|
||||||
### TODO pre-holiday break
|
### TODO pre-holiday break
|
||||||
|
|
||||||
- Update COLUMNS to SORT_COLUMNS and SEARCH_COLUMNS
|
- Update COLUMNS to SORT_COLUMNS and SEARCH_COLUMNS
|
||||||
- Update to using new PF Toolbar component (currently an experimental component)
|
- Update to using new PF Toolbar component (currently an experimental component)
|
||||||
- Change the right-hand input based on the type of key selected on the left-hand side. In addition to text input, for our MVP we will support:
|
- Change the right-hand input based on the type of key selected on the left-hand side. In addition to text input, for our MVP we will support:
|
||||||
- number input
|
- number input
|
||||||
- select input (multiple-choice configured from UI or Options)
|
- select input (multiple-choice configured from UI or Options)
|
||||||
- Update the following lists to have the following keys:
|
- Update the following lists to have the following keys:
|
||||||
|
|
||||||
**Jobs list** (signed off earlier in chat)
|
**Jobs list** (signed off earlier in chat)
|
||||||
- Name (which is also the name of the job template) - search is ?name=jt
|
|
||||||
- Job ID - search is ?id=13
|
- Name (which is also the name of the job template) - search is ?name=jt
|
||||||
- Label name - search is ?labels__name=foo
|
- Job ID - search is ?id=13
|
||||||
- Job type (dropdown on right with the different types) ?type = job
|
- Label name - search is ?labels\_\_name=foo
|
||||||
- Created by (username) - search is ?created_by__username=admin
|
- Job type (dropdown on right with the different types) ?type = job
|
||||||
- Status - search (dropdown on right with different statuses) is ?status=successful
|
- Created by (username) - search is ?created_by\_\_username=admin
|
||||||
|
- Status - search (dropdown on right with different statuses) is ?status=successful
|
||||||
|
|
||||||
Instances of jobs list include:
|
Instances of jobs list include:
|
||||||
- Jobs list
|
|
||||||
- Host completed jobs list
|
- Jobs list
|
||||||
- JT completed jobs list
|
- Host completed jobs list
|
||||||
|
- JT completed jobs list
|
||||||
|
|
||||||
**Organization list**
|
**Organization list**
|
||||||
- Name - search is ?name=org
|
|
||||||
- ? Team name (of a team in the org) - search is ?teams__name=ansible
|
- Name - search is ?name=org
|
||||||
- ? Username (of a user in the org) - search is ?users__username=johndoe
|
- ? Team name (of a team in the org) - search is ?teams\_\_name=ansible
|
||||||
|
- ? Username (of a user in the org) - search is ?users\_\_username=johndoe
|
||||||
|
|
||||||
Instances of orgs list include:
|
Instances of orgs list include:
|
||||||
- Orgs list
|
|
||||||
- User orgs list
|
- Orgs list
|
||||||
- Lookup on Project
|
- User orgs list
|
||||||
- Lookup on Credential
|
- Lookup on Project
|
||||||
- Lookup on Inventory
|
- Lookup on Credential
|
||||||
- User access add wizard list
|
- Lookup on Inventory
|
||||||
- Team access add wizard list
|
- User access add wizard list
|
||||||
|
- Team access add wizard list
|
||||||
|
|
||||||
**Instance Groups list**
|
**Instance Groups list**
|
||||||
- Name - search is ?name=ig
|
|
||||||
- ? is_container_group boolean choice (doesn't work right now in API but will soon) - search is ?is_container_group=true
|
- Name - search is ?name=ig
|
||||||
- ? credential name - search is ?credentials__name=kubey
|
- ? is_container_group boolean choice (doesn't work right now in API but will soon) - search is ?is_container_group=true
|
||||||
|
- ? credential name - search is ?credentials\_\_name=kubey
|
||||||
|
|
||||||
Instance of instance groups list include:
|
Instance of instance groups list include:
|
||||||
- Lookup on Org
|
|
||||||
- Lookup on JT
|
- Lookup on Org
|
||||||
- Lookup on Inventory
|
- Lookup on JT
|
||||||
|
- Lookup on Inventory
|
||||||
|
|
||||||
**Users list**
|
**Users list**
|
||||||
- Username - search is ?username=johndoe
|
|
||||||
- First Name - search is ?first_name=John
|
- Username - search is ?username=johndoe
|
||||||
- Last Name - search is ?last_name=Doe
|
- First Name - search is ?first_name=John
|
||||||
- ? (if not superfluous, would not include on Team users list) Team Name - search is ?teams__name=team_of_john_does (note API issue: User has no field named "teams")
|
- Last Name - search is ?last_name=Doe
|
||||||
- ? (only for access or permissions list) Role Name - search is ?roles__name=Admin (note API issue: Role has no field "name")
|
- ? (if not superfluous, would not include on Team users list) Team Name - search is ?teams\_\_name=team_of_john_does (note API issue: User has no field named "teams")
|
||||||
- ? (if not superfluous, would not include on Organization users list) ORg Name - search is ?organizations__name=org_of_jhn_does
|
- ? (only for access or permissions list) Role Name - search is ?roles\_\_name=Admin (note API issue: Role has no field "name")
|
||||||
|
- ? (if not superfluous, would not include on Organization users list) ORg Name - search is ?organizations\_\_name=org_of_jhn_does
|
||||||
|
|
||||||
Instance of user lists include:
|
Instance of user lists include:
|
||||||
- User list
|
|
||||||
- Org user list
|
- User list
|
||||||
- Access list for Org, JT, Project, Credential, Inventory, User and Team
|
- Org user list
|
||||||
- Access list for JT
|
- Access list for Org, JT, Project, Credential, Inventory, User and Team
|
||||||
- Access list Project
|
- Access list for JT
|
||||||
- Access list for Credential
|
- Access list Project
|
||||||
- Access list for Inventory
|
- Access list for Credential
|
||||||
- Access list for User
|
- Access list for Inventory
|
||||||
- Access list for Team
|
- Access list for User
|
||||||
- Team add users list
|
- Access list for Team
|
||||||
- Users list in access wizard (to add new roles for a particular list) for Org
|
- Team add users list
|
||||||
- Users list in access wizard (to add new roles for a particular list) for JT
|
- Users list in access wizard (to add new roles for a particular list) for Org
|
||||||
- Users list in access wizard (to add new roles for a particular list) for Project
|
- Users list in access wizard (to add new roles for a particular list) for JT
|
||||||
- Users list in access wizard (to add new roles for a particular list) for Credential
|
- Users list in access wizard (to add new roles for a particular list) for Project
|
||||||
- Users list in access wizard (to add new roles for a particular list) for Inventory
|
- Users list in access wizard (to add new roles for a particular list) for Credential
|
||||||
|
- Users list in access wizard (to add new roles for a particular list) for Inventory
|
||||||
|
|
||||||
**Teams list**
|
**Teams list**
|
||||||
- Name - search is ?name=teamname
|
|
||||||
- ? Username (of a user in the team) - search is ?users__username=johndoe
|
- Name - search is ?name=teamname
|
||||||
- ? (if not superfluous, would not include on Organizations teams list) Org Name - search is ?organizations__name=org_of_john_does
|
- ? Username (of a user in the team) - search is ?users\_\_username=johndoe
|
||||||
|
- ? (if not superfluous, would not include on Organizations teams list) Org Name - search is ?organizations\_\_name=org_of_john_does
|
||||||
|
|
||||||
Instance of team lists include:
|
Instance of team lists include:
|
||||||
- Team list
|
|
||||||
- Org team list
|
- Team list
|
||||||
- User team list
|
- Org team list
|
||||||
- Team list in access wizard (to add new roles for a particular list) for Org
|
- User team list
|
||||||
- Team list in access wizard (to add new roles for a particular list) for JT
|
- Team list in access wizard (to add new roles for a particular list) for Org
|
||||||
- Team list in access wizard (to add new roles for a particular list) for Project
|
- Team list in access wizard (to add new roles for a particular list) for JT
|
||||||
- Team list in access wizard (to add new roles for a particular list) for Credential
|
- Team list in access wizard (to add new roles for a particular list) for Project
|
||||||
- Team list in access wizard (to add new roles for a particular list) for Inventory
|
- Team list in access wizard (to add new roles for a particular list) for Credential
|
||||||
|
- Team list in access wizard (to add new roles for a particular list) for Inventory
|
||||||
|
|
||||||
**Credentials list**
|
**Credentials list**
|
||||||
- Name
|
|
||||||
- ? Type (dropdown on right with different types)
|
- Name
|
||||||
- ? Created by (username)
|
- ? Type (dropdown on right with different types)
|
||||||
- ? Modified by (username)
|
- ? Created by (username)
|
||||||
|
- ? Modified by (username)
|
||||||
|
|
||||||
Instance of credential lists include:
|
Instance of credential lists include:
|
||||||
- Credential list
|
|
||||||
- Lookup for JT
|
- Credential list
|
||||||
- Lookup for Project
|
- Lookup for JT
|
||||||
- User access add wizard list
|
- Lookup for Project
|
||||||
- Team access add wizard list
|
- User access add wizard list
|
||||||
|
- Team access add wizard list
|
||||||
|
|
||||||
**Projects list**
|
**Projects list**
|
||||||
- Name - search is ?name=proj
|
|
||||||
- ? Type (dropdown on right with different types) - search is scm_type=git
|
- Name - search is ?name=proj
|
||||||
- ? SCM URL - search is ?scm_url=github.com/ansible/test-playbooks
|
- ? Type (dropdown on right with different types) - search is scm_type=git
|
||||||
- ? Created by (username) - search is ?created_by__username=admin
|
- ? SCM URL - search is ?scm_url=github.com/ansible/test-playbooks
|
||||||
- ? Modified by (username) - search is ?modified_by__username=admin
|
- ? Created by (username) - search is ?created_by\_\_username=admin
|
||||||
|
- ? Modified by (username) - search is ?modified_by\_\_username=admin
|
||||||
|
|
||||||
Instance of project lists include:
|
Instance of project lists include:
|
||||||
- Project list
|
|
||||||
- Lookup for JT
|
- Project list
|
||||||
- User access add wizard list
|
- Lookup for JT
|
||||||
- Team access add wizard list
|
- User access add wizard list
|
||||||
|
- Team access add wizard list
|
||||||
|
|
||||||
**Templates list**
|
**Templates list**
|
||||||
- Name - search is ?name=cleanup
|
|
||||||
- ? Type (dropdown on right with different types) - search is ?type=playbook_run
|
- Name - search is ?name=cleanup
|
||||||
- ? Playbook name - search is ?job_template__playbook=debug.yml
|
- ? Type (dropdown on right with different types) - search is ?type=playbook_run
|
||||||
- ? Created by (username) - search is ?created_by__username=admin
|
- ? Playbook name - search is ?job_template\_\_playbook=debug.yml
|
||||||
- ? Modified by (username) - search is ?modified_by__username=admin
|
- ? Created by (username) - search is ?created_by\_\_username=admin
|
||||||
|
- ? Modified by (username) - search is ?modified_by\_\_username=admin
|
||||||
|
|
||||||
Instance of template lists include:
|
Instance of template lists include:
|
||||||
- Template list
|
|
||||||
- Project Templates list
|
- Template list
|
||||||
|
- Project Templates list
|
||||||
|
|
||||||
**Inventories list**
|
**Inventories list**
|
||||||
- Name - search is ?name=inv
|
|
||||||
- ? Created by (username) - search is ?created_by__username=admin
|
- Name - search is ?name=inv
|
||||||
- ? Modified by (username) - search is ?modified_by__username=admin
|
- ? Created by (username) - search is ?created_by\_\_username=admin
|
||||||
|
- ? Modified by (username) - search is ?modified_by\_\_username=admin
|
||||||
|
|
||||||
Instance of inventory lists include:
|
Instance of inventory lists include:
|
||||||
- Inventory list
|
|
||||||
- Lookup for JT
|
- Inventory list
|
||||||
- User access add wizard list
|
- Lookup for JT
|
||||||
- Team access add wizard list
|
- User access add wizard list
|
||||||
|
- Team access add wizard list
|
||||||
|
|
||||||
**Groups list**
|
**Groups list**
|
||||||
- Name - search is ?name=group_name
|
|
||||||
- ? Created by (username) - search is ?created_by__username=admin
|
- Name - search is ?name=group_name
|
||||||
- ? Modified by (username) - search is ?modified_by__username=admin
|
- ? Created by (username) - search is ?created_by\_\_username=admin
|
||||||
|
- ? Modified by (username) - search is ?modified_by\_\_username=admin
|
||||||
|
|
||||||
Instance of group lists include:
|
Instance of group lists include:
|
||||||
- Group list
|
|
||||||
|
- Group list
|
||||||
|
|
||||||
**Hosts list**
|
**Hosts list**
|
||||||
- Name - search is ?name=hostname
|
|
||||||
- ? Created by (username) - search is ?created_by__username=admin
|
- Name - search is ?name=hostname
|
||||||
- ? Modified by (username) - search is ?modified_by__username=admin
|
- ? Created by (username) - search is ?created_by\_\_username=admin
|
||||||
|
- ? Modified by (username) - search is ?modified_by\_\_username=admin
|
||||||
|
|
||||||
Instance of host lists include:
|
Instance of host lists include:
|
||||||
- Host list
|
|
||||||
|
- Host list
|
||||||
|
|
||||||
**Notifications list**
|
**Notifications list**
|
||||||
- Name - search is ?name=notification_template_name
|
|
||||||
- ? Type (dropdown on right with different types) - search is ?type=slack
|
- Name - search is ?name=notification_template_name
|
||||||
- ? Created by (username) - search is ?created_by__username=admin
|
- ? Type (dropdown on right with different types) - search is ?type=slack
|
||||||
- ? Modified by (username) - search is ?modified_by__username=admin
|
- ? Created by (username) - search is ?created_by\_\_username=admin
|
||||||
|
- ? Modified by (username) - search is ?modified_by\_\_username=admin
|
||||||
|
|
||||||
Instance of notification lists include:
|
Instance of notification lists include:
|
||||||
- Org notification list
|
|
||||||
- JT notification list
|
- Org notification list
|
||||||
- Project notification list
|
- JT notification list
|
||||||
|
- Project notification list
|
||||||
|
|
||||||
### TODO backlog
|
### TODO backlog
|
||||||
- Change the right-hand input based on the type of key selected on the left-hand side. We will eventually want to support:
|
|
||||||
|
- Change the right-hand input based on the type of key selected on the left-hand side. We will eventually want to support:
|
||||||
- lookup input (selection of particular resources, based on API list endpoints)
|
- lookup input (selection of particular resources, based on API list endpoints)
|
||||||
- date picker input
|
- date picker input
|
||||||
- Update the following lists to have the following keys:
|
- Update the following lists to have the following keys:
|
||||||
- Update all __name and __username related field search-based keys to be type-ahead lookup based searches
|
- Update all **name and **username related field search-based keys to be type-ahead lookup based searches
|
||||||
|
|
||||||
## Code Details
|
## Code Details
|
||||||
|
|
||||||
@@ -230,13 +257,13 @@ The component looks like this:
|
|||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
**qsConfig** is used to get namespace so that multiple lists can be on the page. When tags are modified they append namespace to query params. The qsConfig is also used to get "type" of fields in order to correctly parse values as int or date as it is translating.
|
**qsConfig** is used to get namespace so that multiple lists can be on the page. When tags are modified they append namespace to query params. The qsConfig is also used to get "type" of fields in order to correctly parse values as int or date as it is translating.
|
||||||
|
|
||||||
**columns** are passed as an array, as defined in the screen where the list is located. You pass a bool `isDefault` to indicate that should be the key that shows up in the left-hand dropdown as default in the UI. If you don't pass any columns, a default of `isDefault=true` will be added to a name column, which is nearly universally shared throughout the models of awx.
|
**columns** are passed as an array, as defined in the screen where the list is located. You pass a bool `isDefault` to indicate that should be the key that shows up in the left-hand dropdown as default in the UI. If you don't pass any columns, a default of `isDefault=true` will be added to a name column, which is nearly universally shared throughout the models of awx.
|
||||||
|
|
||||||
There is a type attribute that can be `'string'`, `'number'` or `'choice'` (and in the future, `'date'` and `'lookup'`), which will change the type of input on the right-hand side of the search bar. For a key that has a set number of choices, you will pass a choices attribute, which is an array in the format choices: [{label: 'Foo', value: 'foo'}]
|
There is a type attribute that can be `'string'`, `'number'` or `'choice'` (and in the future, `'date'` and `'lookup'`), which will change the type of input on the right-hand side of the search bar. For a key that has a set number of choices, you will pass a choices attribute, which is an array in the format choices: [{label: 'Foo', value: 'foo'}]
|
||||||
|
|
||||||
**onSearch** calls the `mergeParams` qs util in order to add new tags to the queryset. mergeParams is used so that we can support duplicate keys (see mergeParams vs. replaceParams for more info).
|
**onSearch** calls the `mergeParams` qs util in order to add new tags to the queryset. mergeParams is used so that we can support duplicate keys (see mergeParams vs. replaceParams for more info).
|
||||||
|
|
||||||
### ListHeader component
|
### ListHeader component
|
||||||
|
|
||||||
@@ -253,15 +280,16 @@ All of these functions act on the react-router history using the `pushHistorySta
|
|||||||
|
|
||||||
**a note on sort_columns and search_columns**
|
**a note on sort_columns and search_columns**
|
||||||
|
|
||||||
We have split out column configuration into separate search and sort column array props--these are passed to the search and sort columns. Both accept an isDefault prop for one of the items in the array to be the default option selected when going to the page. Sort column items can pass an isNumeric boolean in order to chnage the iconography of the sort UI element. Search column items can pass type and if applicable choices, in order to configure the right-hand side of the search bar.
|
We have split out column configuration into separate search and sort column array props--these are passed to the search and sort columns. Both accept an isDefault prop for one of the items in the array to be the default option selected when going to the page. Sort column items can pass an isNumeric boolean in order to chnage the iconography of the sort UI element. Search column items can pass type and if applicable choices, in order to configure the right-hand side of the search bar.
|
||||||
|
|
||||||
### FilterTags component
|
### FilterTags component
|
||||||
|
|
||||||
Similar to the way the list grabs data based on changes to the react-router params, the `FilterTags` component updates when new params are added. This component is a fairly straight-forward map (only slightly complex, because it needed to do a nested map over any values with duplicate keys that were represented by an inner-array). Both key and value are displayed for the tag.
|
Similar to the way the list grabs data based on changes to the react-router params, the `FilterTags` component updates when new params are added. This component is a fairly straight-forward map (only slightly complex, because it needed to do a nested map over any values with duplicate keys that were represented by an inner-array). Both key and value are displayed for the tag.
|
||||||
|
|
||||||
### qs utility
|
### qs utility
|
||||||
|
|
||||||
The qs (queryset) utility is used to make the search speak the language of the REST API. The main functions of the utilities are to:
|
The qs (queryset) utility is used to make the search speak the language of the REST API. The main functions of the utilities are to:
|
||||||
|
|
||||||
- add, replace and remove filters
|
- add, replace and remove filters
|
||||||
- translate filters as url params (for linking and maintaining state), in-memory representation (as JS objects), and params that Django REST Framework understands.
|
- translate filters as url params (for linking and maintaining state), in-memory representation (as JS objects), and params that Django REST Framework understands.
|
||||||
|
|
||||||
@@ -269,7 +297,7 @@ More info in the below sections:
|
|||||||
|
|
||||||
#### Encoding for UI vs. API
|
#### Encoding for UI vs. API
|
||||||
|
|
||||||
For the UI url params, we want to only encode those params that aren't defaults, as the default behavior was defined through configuration and we don't need these in the url as a source of truth. For the API, we need to pass these params so that they are taken into account when the response is built.
|
For the UI url params, we want to only encode those params that aren't defaults, as the default behavior was defined through configuration and we don't need these in the url as a source of truth. For the API, we need to pass these params so that they are taken into account when the response is built.
|
||||||
|
|
||||||
#### mergeParams vs. replaceParams
|
#### mergeParams vs. replaceParams
|
||||||
|
|
||||||
@@ -283,13 +311,13 @@ From a UX perspective, we wanted to be able to support searching on the same key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Concatenating terms in this way gives you the intersection of both terms (i.e. foo must be "bar" and "baz"). This is helpful for the most-common type of searching, substring (`__icontains`) searches. This will increase filtering, allowing the user to drill-down into the list as terms are added.
|
Concatenating terms in this way gives you the intersection of both terms (i.e. foo must be "bar" and "baz"). This is helpful for the most-common type of searching, substring (`__icontains`) searches. This will increase filtering, allowing the user to drill-down into the list as terms are added.
|
||||||
|
|
||||||
**replaceParams** is used to support sorting, setting page_size, etc. These params only allow one choice, and we need to replace a particular key's value if one is passed.
|
**replaceParams** is used to support sorting, setting page_size, etc. These params only allow one choice, and we need to replace a particular key's value if one is passed.
|
||||||
|
|
||||||
#### Working with REST API
|
#### Working with REST API
|
||||||
|
|
||||||
The REST API is coupled with the qs util through the `paramsSerializer`, due to the fact we need axios to support the array for duplicate key values in the object representation of the params to pass to the get request. This is done where axios is configured in the Base.js file, so all requests and request types should support our array syntax for duplicate keys automatically.
|
The REST API is coupled with the qs util through the `paramsSerializer`, due to the fact we need axios to support the array for duplicate key values in the object representation of the params to pass to the get request. This is done where axios is configured in the Base.js file, so all requests and request types should support our array syntax for duplicate keys automatically.
|
||||||
|
|
||||||
# Advanced Search - this section is a mess, update eventually
|
# Advanced Search - this section is a mess, update eventually
|
||||||
|
|
||||||
@@ -305,85 +333,84 @@ Current thinking is Advanced Search will be post-3.6, or at least late 3.6 after
|
|||||||
|
|
||||||
That being said, we want to plan it out so we make sure the infrastructure of how we set up adding/removing tags, what shows up in the url bar, etc. all doesn't have to be redone.
|
That being said, we want to plan it out so we make sure the infrastructure of how we set up adding/removing tags, what shows up in the url bar, etc. all doesn't have to be redone.
|
||||||
|
|
||||||
Users will get to advanced search with a button to the right of search bar. When selected type-ahead key thing opens, left dropdown of search bar goes away, and x is given to get back to regular search (this is in the mockups)
|
Users will get to advanced search with a button to the right of search bar. When selected type-ahead key thing opens, left dropdown of search bar goes away, and x is given to get back to regular search (this is in the mockups)
|
||||||
|
|
||||||
It is okay to only make this typing representation available initially (i.e. they start doing stuff with the type-ahead and the phases, no more typing in to make a query that way).
|
It is okay to only make this typing representation available initially (i.e. they start doing stuff with the type-ahead and the phases, no more typing in to make a query that way).
|
||||||
|
|
||||||
when you click through or type in the search bar for the various phases of crafting the query ("not", "related resource project", "related resource key name", "value foo") which might be represented in the top bar as a series of tags that can be added and removed before submitting the tag.
|
when you click through or type in the search bar for the various phases of crafting the query ("not", "related resource project", "related resource key name", "value foo") which might be represented in the top bar as a series of tags that can be added and removed before submitting the tag.
|
||||||
|
|
||||||
We will try to form options data from a static file. Because options data is static, we may be able to generate and store as a static file of some sort (that we can use for managing smart search). Alan had ideas around this. If we do this it will mean we don't have to make a ton of requests as we craft smart search filters. It sounds like the cli may start using something similar.
|
We will try to form options data from a static file. Because options data is static, we may be able to generate and store as a static file of some sort (that we can use for managing smart search). Alan had ideas around this. If we do this it will mean we don't have to make a ton of requests as we craft smart search filters. It sounds like the cli may start using something similar.
|
||||||
|
|
||||||
## Smart search flow
|
## Smart search flow
|
||||||
|
|
||||||
Smart search will be able to craft the tag through various states. Note that the phases don't necessarily need to be completed in sequential order.
|
Smart search will be able to craft the tag through various states. Note that the phases don't necessarily need to be completed in sequential order.
|
||||||
|
|
||||||
PHASE 1: prefix operators
|
PHASE 1: prefix operators
|
||||||
|
|
||||||
**TODO: Double check there's no reason we need to include or__ and chain__ and can just do not__**
|
**TODO: Double check there's no reason we need to include or** and chain** and can just do not\_\_**
|
||||||
|
|
||||||
- not__
|
- not\_\_
|
||||||
- or__
|
- or\_\_
|
||||||
- chain__
|
- chain\_\_
|
||||||
|
|
||||||
how these work:
|
how these work:
|
||||||
|
|
||||||
To exclude results matching certain criteria, prefix the field parameter with not__:
|
To exclude results matching certain criteria, prefix the field parameter with not\_\_:
|
||||||
|
|
||||||
?not__field=value
|
?not**field=value
|
||||||
By default, all query string filters are AND'ed together, so only the results matching all filters will be returned. To combine results matching any one of multiple criteria, prefix each query string parameter with or__:
|
By default, all query string filters are AND'ed together, so only the results matching all filters will be returned. To combine results matching any one of multiple criteria, prefix each query string parameter with or**:
|
||||||
|
|
||||||
?or__field=value&or__field=othervalue
|
?or**field=value&or**field=othervalue
|
||||||
?or__not__field=value&or__field=othervalue
|
?or**not**field=value&or**field=othervalue
|
||||||
(Added in Ansible Tower 1.4.5) The default AND filtering applies all filters simultaneously to each related object being filtered across database relationships. The chain filter instead applies filters separately for each related object. To use, prefix the query string parameter with chain__:
|
(Added in Ansible Controller 1.4.5) The default AND filtering applies all filters simultaneously to each related object being filtered across database relationships. The chain filter instead applies filters separately for each related object. To use, prefix the query string parameter with chain**:
|
||||||
|
|
||||||
?chain__related__field=value&chain__related__field2=othervalue
|
?chain**related**field=value&chain**related**field2=othervalue
|
||||||
?chain__not__related__field=value&chain__related__field2=othervalue
|
?chain**not**related**field=value&chain**related**field2=othervalue
|
||||||
If the first query above were written as ?related__field=value&related__field2=othervalue, it would return only the primary objects where the same related object satisfied both conditions. As written using the chain filter, it would return the intersection of primary objects matching each condition.
|
If the first query above were written as ?related**field=value&related\_\_field2=othervalue, it would return only the primary objects where the same related object satisfied both conditions. As written using the chain filter, it would return the intersection of primary objects matching each condition.
|
||||||
|
|
||||||
PHASE 2: related fields, given by array, where __search is appended to them, i.e.
|
PHASE 2: related fields, given by array, where \_\_search is appended to them, i.e.
|
||||||
|
|
||||||
```
|
```
|
||||||
"related_search_fields": [
|
"related_search_fields": [
|
||||||
"credentials__search",
|
"credentials__search",
|
||||||
"labels__search",
|
"labels__search",
|
||||||
"created_by__search",
|
"created_by__search",
|
||||||
"modified_by__search",
|
"modified_by__search",
|
||||||
"notification_templates__search",
|
"notification_templates__search",
|
||||||
"custom_inventory_scripts__search",
|
"custom_inventory_scripts__search",
|
||||||
"notification_templates_error__search",
|
"notification_templates_error__search",
|
||||||
"notification_templates_success__search",
|
"notification_templates_success__search",
|
||||||
"notification_templates_any__search",
|
"notification_templates_any__search",
|
||||||
"teams__search",
|
"teams__search",
|
||||||
"projects__search",
|
"projects__search",
|
||||||
"inventories__search",
|
"inventories__search",
|
||||||
"applications__search",
|
"applications__search",
|
||||||
"workflows__search",
|
"workflows__search",
|
||||||
"instance_groups__search"
|
"instance_groups__search"
|
||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|
||||||
PHASE 3: keys, give by object key names for data.actions.GET
|
PHASE 3: keys, give by object key names for data.actions.GET - type is given for each key which we could use to help craft the value
|
||||||
- type is given for each key which we could use to help craft the value
|
|
||||||
|
|
||||||
PHASE 4: after key postfix operators can be
|
PHASE 4: after key postfix operators can be
|
||||||
|
|
||||||
**TODO: will need to figure out which ones we support**
|
**TODO: will need to figure out which ones we support**
|
||||||
|
|
||||||
- exact: Exact match (default lookup if not specified).
|
- exact: Exact match (default lookup if not specified).
|
||||||
- iexact: Case-insensitive version of exact.
|
- iexact: Case-insensitive version of exact.
|
||||||
- contains: Field contains value.
|
- contains: Field contains value.
|
||||||
- icontains: Case-insensitive version of contains.
|
- icontains: Case-insensitive version of contains.
|
||||||
- startswith: Field starts with value.
|
- startswith: Field starts with value.
|
||||||
- istartswith: Case-insensitive version of startswith.
|
- istartswith: Case-insensitive version of startswith.
|
||||||
- endswith: Field ends with value.
|
- endswith: Field ends with value.
|
||||||
- iendswith: Case-insensitive version of endswith.
|
- iendswith: Case-insensitive version of endswith.
|
||||||
- regex: Field matches the given regular expression.
|
- regex: Field matches the given regular expression.
|
||||||
- iregex: Case-insensitive version of regex.
|
- iregex: Case-insensitive version of regex.
|
||||||
- gt: Greater than comparison.
|
- gt: Greater than comparison.
|
||||||
- gte: Greater than or equal to comparison.
|
- gte: Greater than or equal to comparison.
|
||||||
- lt: Less than comparison.
|
- lt: Less than comparison.
|
||||||
- lte: Less than or equal to comparison.
|
- lte: Less than or equal to comparison.
|
||||||
- isnull: Check whether the given field or related object is null; expects a boolean value.
|
- isnull: Check whether the given field or related object is null; expects a boolean value.
|
||||||
- in: Check whether the given field's value is present in the list provided; expects a list of items.
|
- in: Check whether the given field's value is present in the list provided; expects a list of items.
|
||||||
|
|
||||||
PHASE 5: The value. Based on options, we can give hints or validation based on type of value (like number fields don't accept "foo" or whatever)
|
PHASE 5: The value. Based on options, we can give hints or validation based on type of value (like number fields don't accept "foo" or whatever)
|
||||||
|
|||||||
@@ -2,15 +2,39 @@ This document is meant to provide some guidance into the functionality of Job Ou
|
|||||||
|
|
||||||
## Overview of the feature/screen. Summary of what it does/is
|
## Overview of the feature/screen. Summary of what it does/is
|
||||||
|
|
||||||
1. Elapsed time / unfollow button
|
Joboutput is a feature that allows users to see how their job is doing as it is being run.
|
||||||
2. Page up and page down buttons
|
This feature displays data sent to the UI via websockets that are connected to several
|
||||||
3. Unique qualities of the different job types.
|
different endpoints in the API.
|
||||||
|
|
||||||
- Some don’t allow search by event data and thus Event is not an option in the drop down
|
The job output has 2 different states that result in different functionality. One state
|
||||||
- Some don’t have expand, collapse
|
is when, the job is actively running. There is limited functionality because of how the
|
||||||
|
job events are processed when they reach the UI. While the job is running, and
|
||||||
|
output is coming into the UI, the following features turn off:
|
||||||
|
|
||||||
4. Differences in the output from when a job is running and when a job is complete.
|
1. [Search](#Search)- The ability to search the output of a job.
|
||||||
5. Which features are enabled when it’s running and which aren’t.
|
2. [Expand/Collapse](#Expand/Collapse)- The ability to expand and collapse job events, tasks, plays, or even the
|
||||||
|
job itself. The only part of the job ouput that is not collapsable is the playbook summary (only jobs that
|
||||||
|
are executed from a Job Template have Expand/Collapse functionality).
|
||||||
|
|
||||||
|
The following features are enabled:
|
||||||
|
|
||||||
|
1. Follow/unfollow - `Follow` indicates you are streaming the output on the screen
|
||||||
|
as it comes into the UI. If you see some output that you want to examine closer while the job is running
|
||||||
|
scroll to it, and click `Unfollow`, and the output will stop streaming onto the screen. This feature is only
|
||||||
|
enabled when the job is running and is not complete. If the user scrolls up in the output the UI will unfollow.
|
||||||
|
2. Page up and page down buttons- Use these buttons to navigate quickly up and down the output.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
After the job is complete, the Follow/Unfollow button disabled, and Expand/Collapse and Search become enabled.
|
||||||
|

|
||||||
|
|
||||||
|
Not all job types are created equal. Some jobs have a concept of parent-child events. Job events can be inside a Task,
|
||||||
|
a Task can be inside a Play, and a Play inside a Playbook. Leveraging this concept to enable Expand/Collapse for these
|
||||||
|
job types, allows you to collapse and hide the children of a particular line of output. This parent-child event
|
||||||
|
relationship only exists on jobs executed from a job template. All other types of jobs do not
|
||||||
|
have this event concept, and therefore, do not have Expand/Collapse functionality. By default all job
|
||||||
|
events are expanded.
|
||||||
|
|
||||||
## How output works generally.
|
## How output works generally.
|
||||||
|
|
||||||
@@ -26,11 +50,13 @@ This document is meant to provide some guidance into the functionality of Job Ou
|
|||||||
## Non-standard cases
|
## Non-standard cases
|
||||||
|
|
||||||
1. When an event comes into the output that has a parent, but the parent hasn’t arrived yet.
|
1. When an event comes into the output that has a parent, but the parent hasn’t arrived yet.
|
||||||
2. When an event that has children arrives in output, but the children are not present yet
|
2. When an event with children arrives in output, but the children are not yet present.
|
||||||
|
|
||||||
## Expand collapse a single event- how it works and how it changes the state object
|
## Expand/Collapse
|
||||||
|
|
||||||
## Expand collapse all- how it works and how it changes the state object
|
### Expand collapse a single event - how it works and how it changes the state object
|
||||||
|
|
||||||
|
### Expand collapse all - how it works and how it changes the state object
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
|
|||||||
BIN
awx/ui/docs/images/JobOutput-complete.png
Normal file
BIN
awx/ui/docs/images/JobOutput-complete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
awx/ui/docs/images/JobOutput-running.png
Normal file
BIN
awx/ui/docs/images/JobOutput-running.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
1235
awx/ui/package-lock.json
generated
1235
awx/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,31 +6,31 @@
|
|||||||
"node": ">=16.13.1"
|
"node": ">=16.13.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lingui/react": "3.13.3",
|
"@lingui/react": "3.14.0",
|
||||||
"@patternfly/patternfly": "4.194.4",
|
"@patternfly/patternfly": "4.202.1",
|
||||||
"@patternfly/react-core": "^4.201.0",
|
"@patternfly/react-core": "^4.221.3",
|
||||||
"@patternfly/react-icons": "4.49.19",
|
"@patternfly/react-icons": "4.75.1",
|
||||||
"@patternfly/react-table": "4.83.1",
|
"@patternfly/react-table": "4.93.1",
|
||||||
"ace-builds": "^1.5.1",
|
"ace-builds": "^1.6.0",
|
||||||
"ansi-to-html": "0.7.2",
|
"ansi-to-html": "0.7.2",
|
||||||
"axios": "0.22.0",
|
"axios": "0.27.2",
|
||||||
"codemirror": "^5.65.4",
|
"codemirror": "^6.0.1",
|
||||||
"d3": "7.4.4",
|
"d3": "7.4.4",
|
||||||
"dagre": "^0.8.4",
|
"dagre": "^0.8.4",
|
||||||
|
"dompurify": "2.3.8",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"has-ansi": "5.0.1",
|
"has-ansi": "5.0.1",
|
||||||
"html-entities": "2.3.2",
|
"html-entities": "2.3.2",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "4.1.0",
|
||||||
"luxon": "^2.4.0",
|
"luxon": "^2.4.0",
|
||||||
"prop-types": "^15.6.2",
|
"prop-types": "^15.6.2",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-ace": "^9.3.0",
|
"react-ace": "^10.1.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-router-dom": "^5.1.2",
|
"react-router-dom": "^5.3.3",
|
||||||
"react-virtualized": "^9.21.1",
|
"react-virtualized": "^9.21.1",
|
||||||
"rrule": "2.6.4",
|
"rrule": "2.7.0",
|
||||||
"sanitize-html": "2.4.0",
|
|
||||||
"styled-components": "5.3.5"
|
"styled-components": "5.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io https://d3js.org; img-src 'self' *.pendo.io data:; worker-src 'self';"
|
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io https://d3js.org; img-src 'self' *.pendo.io data:; worker-src 'self' blob: ;"
|
||||||
/>
|
/>
|
||||||
<link rel="shortcut icon" href="{% static 'media/favicon.ico' %}" />
|
<link rel="shortcut icon" href="{% static 'media/favicon.ico' %}" />
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ const AuthorizedRoutes = ({ routeConfig }) => {
|
|||||||
export function ProtectedRoute({ children, ...rest }) {
|
export function ProtectedRoute({ children, ...rest }) {
|
||||||
const {
|
const {
|
||||||
authRedirectTo,
|
authRedirectTo,
|
||||||
setAuthRedirectTo,
|
|
||||||
loginRedirectOverride,
|
|
||||||
isUserBeingLoggedOut,
|
isUserBeingLoggedOut,
|
||||||
|
loginRedirectOverride,
|
||||||
|
setAuthRedirectTo,
|
||||||
} = useSession();
|
} = useSession();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ class Organizations extends InstanceGroupsMixin(NotificationsMixin(Base)) {
|
|||||||
disassociate: true,
|
disassociate: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readAdmins(id, params) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/admins/`, { params });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Organizations;
|
export default Organizations;
|
||||||
|
|||||||
@@ -26,13 +26,6 @@ function AdHocCommands({
|
|||||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
||||||
|
|
||||||
const verbosityOptions = [
|
|
||||||
{ value: '0', key: '0', label: t`0 (Normal)` },
|
|
||||||
{ value: '1', key: '1', label: t`1 (Verbose)` },
|
|
||||||
{ value: '2', key: '2', label: t`2 (More Verbose)` },
|
|
||||||
{ value: '3', key: '3', label: t`3 (Debug)` },
|
|
||||||
{ value: '4', key: '4', label: t`4 (Connection Debug)` },
|
|
||||||
];
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isKebabified) {
|
if (isKebabified) {
|
||||||
onKebabModalChange(isWizardOpen);
|
onKebabModalChange(isWizardOpen);
|
||||||
@@ -159,7 +152,6 @@ function AdHocCommands({
|
|||||||
adHocItems={adHocItems}
|
adHocItems={adHocItems}
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
moduleOptions={moduleOptions}
|
moduleOptions={moduleOptions}
|
||||||
verbosityOptions={verbosityOptions}
|
|
||||||
credentialTypeId={credentialTypeId}
|
credentialTypeId={credentialTypeId}
|
||||||
onCloseWizard={() => setIsWizardOpen(false)}
|
onCloseWizard={() => setIsWizardOpen(false)}
|
||||||
onLaunch={handleSubmit}
|
onLaunch={handleSubmit}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { t } from '@lingui/macro';
|
|||||||
import { withFormik, useFormikContext } from 'formik';
|
import { withFormik, useFormikContext } from 'formik';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { VERBOSITY } from 'components/VerbositySelectField';
|
||||||
import Wizard from '../Wizard';
|
import Wizard from '../Wizard';
|
||||||
import useAdHocLaunchSteps from './useAdHocLaunchSteps';
|
import useAdHocLaunchSteps from './useAdHocLaunchSteps';
|
||||||
|
|
||||||
function AdHocCommandsWizard({
|
function AdHocCommandsWizard({
|
||||||
onLaunch,
|
onLaunch,
|
||||||
moduleOptions,
|
moduleOptions,
|
||||||
verbosityOptions,
|
|
||||||
onCloseWizard,
|
onCloseWizard,
|
||||||
credentialTypeId,
|
credentialTypeId,
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -18,7 +18,6 @@ function AdHocCommandsWizard({
|
|||||||
|
|
||||||
const { steps, validateStep, visitStep, visitAllSteps } = useAdHocLaunchSteps(
|
const { steps, validateStep, visitStep, visitAllSteps } = useAdHocLaunchSteps(
|
||||||
moduleOptions,
|
moduleOptions,
|
||||||
verbosityOptions,
|
|
||||||
organizationId,
|
organizationId,
|
||||||
credentialTypeId
|
credentialTypeId
|
||||||
);
|
);
|
||||||
@@ -57,13 +56,13 @@ function AdHocCommandsWizard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FormikApp = withFormik({
|
const FormikApp = withFormik({
|
||||||
mapPropsToValues({ adHocItems, verbosityOptions }) {
|
mapPropsToValues({ adHocItems }) {
|
||||||
const adHocItemStrings = adHocItems.map((item) => item.name).join(', ');
|
const adHocItemStrings = adHocItems.map((item) => item.name).join(', ');
|
||||||
return {
|
return {
|
||||||
limit: adHocItemStrings || 'all',
|
limit: adHocItemStrings || 'all',
|
||||||
credentials: [],
|
credentials: [],
|
||||||
module_args: '',
|
module_args: '',
|
||||||
verbosity: verbosityOptions[0].value,
|
verbosity: VERBOSITY()[0],
|
||||||
forks: 0,
|
forks: 0,
|
||||||
diff_mode: false,
|
diff_mode: false,
|
||||||
become_enabled: '',
|
become_enabled: '',
|
||||||
@@ -79,7 +78,6 @@ const FormikApp = withFormik({
|
|||||||
FormikApp.propTypes = {
|
FormikApp.propTypes = {
|
||||||
onLaunch: PropTypes.func.isRequired,
|
onLaunch: PropTypes.func.isRequired,
|
||||||
moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
|
moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
|
||||||
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
onCloseWizard: PropTypes.func.isRequired,
|
onCloseWizard: PropTypes.func.isRequired,
|
||||||
credentialTypeId: PropTypes.number.isRequired,
|
credentialTypeId: PropTypes.number.isRequired,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ jest.mock('../../api/models/Credentials');
|
|||||||
jest.mock('../../api/models/ExecutionEnvironments');
|
jest.mock('../../api/models/ExecutionEnvironments');
|
||||||
jest.mock('../../api/models/Root');
|
jest.mock('../../api/models/Root');
|
||||||
|
|
||||||
const verbosityOptions = [
|
|
||||||
{ value: '0', key: '0', label: '0 (Normal)' },
|
|
||||||
{ value: '1', key: '1', label: '1 (Verbose)' },
|
|
||||||
{ value: '2', key: '2', label: '2 (More Verbose)' },
|
|
||||||
{ value: '3', key: '3', label: '3 (Debug)' },
|
|
||||||
{ value: '4', key: '4', label: '4 (Connection Debug)' },
|
|
||||||
];
|
|
||||||
const moduleOptions = [
|
const moduleOptions = [
|
||||||
['command', 'command'],
|
['command', 'command'],
|
||||||
['shell', 'shell'],
|
['shell', 'shell'],
|
||||||
@@ -44,7 +37,6 @@ describe('<AdHocCommandsWizard/>', () => {
|
|||||||
adHocItems={adHocItems}
|
adHocItems={adHocItems}
|
||||||
onLaunch={onLaunch}
|
onLaunch={onLaunch}
|
||||||
moduleOptions={moduleOptions}
|
moduleOptions={moduleOptions}
|
||||||
verbosityOptions={verbosityOptions}
|
|
||||||
onCloseWizard={() => {}}
|
onCloseWizard={() => {}}
|
||||||
credentialTypeId={1}
|
credentialTypeId={1}
|
||||||
organizationId={1}
|
organizationId={1}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Form, FormGroup, Switch, Checkbox } from '@patternfly/react-core';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { required } from 'util/validators';
|
import { required } from 'util/validators';
|
||||||
import useBrandName from 'hooks/useBrandName';
|
import useBrandName from 'hooks/useBrandName';
|
||||||
|
import { VerbositySelectField } from 'components/VerbositySelectField';
|
||||||
import AnsibleSelect from '../AnsibleSelect';
|
import AnsibleSelect from '../AnsibleSelect';
|
||||||
import FormField from '../FormField';
|
import FormField from '../FormField';
|
||||||
import { VariablesField } from '../CodeEditor';
|
import { VariablesField } from '../CodeEditor';
|
||||||
@@ -21,7 +22,7 @@ const TooltipWrapper = styled.div`
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
|
function AdHocDetailsStep({ moduleOptions }) {
|
||||||
const brandName = useBrandName();
|
const brandName = useBrandName();
|
||||||
const [moduleNameField, moduleNameMeta, moduleNameHelpers] = useField({
|
const [moduleNameField, moduleNameMeta, moduleNameHelpers] = useField({
|
||||||
name: 'module_name',
|
name: 'module_name',
|
||||||
@@ -32,7 +33,7 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
|
|||||||
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
|
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
|
||||||
const [becomeEnabledField, , becomeEnabledHelpers] =
|
const [becomeEnabledField, , becomeEnabledHelpers] =
|
||||||
useField('become_enabled');
|
useField('become_enabled');
|
||||||
const [verbosityField, verbosityMeta, verbosityHelpers] = useField({
|
const [, verbosityMeta] = useField({
|
||||||
name: 'verbosity',
|
name: 'verbosity',
|
||||||
validate: required(null),
|
validate: required(null),
|
||||||
});
|
});
|
||||||
@@ -122,33 +123,16 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FormGroup
|
|
||||||
|
<VerbositySelectField
|
||||||
fieldId="verbosity"
|
fieldId="verbosity"
|
||||||
aria-label={t`select verbosity`}
|
tooltip={t`These are the verbosity levels for standard out of the command run that are supported.`}
|
||||||
label={t`Verbosity`}
|
isValid={
|
||||||
isRequired
|
|
||||||
validated={
|
|
||||||
!verbosityMeta.touched || !verbosityMeta.error
|
!verbosityMeta.touched || !verbosityMeta.error
|
||||||
? 'default'
|
? 'default'
|
||||||
: 'error'
|
: 'error'
|
||||||
}
|
}
|
||||||
helperTextInvalid={verbosityMeta.error}
|
/>
|
||||||
labelIcon={
|
|
||||||
<Popover
|
|
||||||
content={t`These are the verbosity levels for standard out of the command run that are supported.`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AnsibleSelect
|
|
||||||
{...verbosityField}
|
|
||||||
isValid={!verbosityMeta.touched || !verbosityMeta.error}
|
|
||||||
id="verbosity"
|
|
||||||
data={verbosityOptions || []}
|
|
||||||
onChange={(event, value) => {
|
|
||||||
verbosityHelpers.setValue(parseInt(value, 10));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<FormField
|
<FormField
|
||||||
id="limit"
|
id="limit"
|
||||||
name="limit"
|
name="limit"
|
||||||
@@ -296,7 +280,6 @@ function AdHocDetailsStep({ verbosityOptions, moduleOptions }) {
|
|||||||
|
|
||||||
AdHocDetailsStep.propTypes = {
|
AdHocDetailsStep.propTypes = {
|
||||||
moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
|
moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
|
||||||
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdHocDetailsStep;
|
export default AdHocDetailsStep;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { t } from '@lingui/macro';
|
|||||||
import { Tooltip } from '@patternfly/react-core';
|
import { Tooltip } from '@patternfly/react-core';
|
||||||
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
|
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { VERBOSITY } from '../VerbositySelectField';
|
||||||
import { toTitleCase } from '../../util/strings';
|
import { toTitleCase } from '../../util/strings';
|
||||||
import { VariablesDetail } from '../CodeEditor';
|
import { VariablesDetail } from '../CodeEditor';
|
||||||
import { jsonToYaml } from '../../util/yaml';
|
import { jsonToYaml } from '../../util/yaml';
|
||||||
@@ -21,7 +22,7 @@ const ErrorMessageWrapper = styled.div`
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
`;
|
`;
|
||||||
function AdHocPreviewStep({ hasErrors, values }) {
|
function AdHocPreviewStep({ hasErrors, values }) {
|
||||||
const { credential, execution_environment, extra_vars } = values;
|
const { credential, execution_environment, extra_vars, verbosity } = values;
|
||||||
|
|
||||||
const items = Object.entries(values);
|
const items = Object.entries(values);
|
||||||
return (
|
return (
|
||||||
@@ -44,6 +45,7 @@ function AdHocPreviewStep({ hasErrors, values }) {
|
|||||||
key !== 'extra_vars' &&
|
key !== 'extra_vars' &&
|
||||||
key !== 'execution_environment' &&
|
key !== 'execution_environment' &&
|
||||||
key !== 'credentials' &&
|
key !== 'credentials' &&
|
||||||
|
key !== 'verbosity' &&
|
||||||
!key.startsWith('credential_passwords') && (
|
!key.startsWith('credential_passwords') && (
|
||||||
<Detail key={key} label={toTitleCase(key)} value={value} />
|
<Detail key={key} label={toTitleCase(key)} value={value} />
|
||||||
)
|
)
|
||||||
@@ -57,6 +59,9 @@ function AdHocPreviewStep({ hasErrors, values }) {
|
|||||||
value={execution_environment[0]?.name}
|
value={execution_environment[0]?.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{verbosity && (
|
||||||
|
<Detail label={t`Verbosity`} value={VERBOSITY()[values.verbosity]} />
|
||||||
|
)}
|
||||||
{extra_vars && (
|
{extra_vars && (
|
||||||
<VariablesDetail
|
<VariablesDetail
|
||||||
value={jsonToYaml(JSON.stringify(extra_vars))}
|
value={jsonToYaml(JSON.stringify(extra_vars))}
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import StepName from '../LaunchPrompt/steps/StepName';
|
|||||||
import AdHocDetailsStep from './AdHocDetailsStep';
|
import AdHocDetailsStep from './AdHocDetailsStep';
|
||||||
|
|
||||||
const STEP_ID = 'details';
|
const STEP_ID = 'details';
|
||||||
export default function useAdHocDetailsStep(
|
export default function useAdHocDetailsStep(visited, moduleOptions) {
|
||||||
visited,
|
|
||||||
moduleOptions,
|
|
||||||
verbosityOptions
|
|
||||||
) {
|
|
||||||
const { values, touched, setFieldError } = useFormikContext();
|
const { values, touched, setFieldError } = useFormikContext();
|
||||||
|
|
||||||
const hasError = () => {
|
const hasError = () => {
|
||||||
@@ -39,12 +35,7 @@ export default function useAdHocDetailsStep(
|
|||||||
{t`Details`}
|
{t`Details`}
|
||||||
</StepName>
|
</StepName>
|
||||||
),
|
),
|
||||||
component: (
|
component: <AdHocDetailsStep moduleOptions={moduleOptions} />,
|
||||||
<AdHocDetailsStep
|
|
||||||
moduleOptions={moduleOptions}
|
|
||||||
verbosityOptions={verbosityOptions}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableNext: true,
|
enableNext: true,
|
||||||
nextButtonText: t`Next`,
|
nextButtonText: t`Next`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ function showCredentialPasswordsStep(credential) {
|
|||||||
|
|
||||||
export default function useAdHocLaunchSteps(
|
export default function useAdHocLaunchSteps(
|
||||||
moduleOptions,
|
moduleOptions,
|
||||||
verbosityOptions,
|
|
||||||
organizationId,
|
organizationId,
|
||||||
credentialTypeId
|
credentialTypeId
|
||||||
) {
|
) {
|
||||||
@@ -32,7 +31,7 @@ export default function useAdHocLaunchSteps(
|
|||||||
|
|
||||||
const [visited, setVisited] = useState({});
|
const [visited, setVisited] = useState({});
|
||||||
const steps = [
|
const steps = [
|
||||||
useAdHocDetailsStep(visited, moduleOptions, verbosityOptions),
|
useAdHocDetailsStep(visited, moduleOptions),
|
||||||
useAdHocExecutionEnvironmentStep(organizationId),
|
useAdHocExecutionEnvironmentStep(organizationId),
|
||||||
useAdHocCredentialStep(visited, credentialTypeId),
|
useAdHocCredentialStep(visited, credentialTypeId),
|
||||||
useCredentialPasswordsStep(
|
useCredentialPasswordsStep(
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ function AnsibleSelect({
|
|||||||
value={option.value}
|
value={option.value}
|
||||||
label={option.label}
|
label={option.label}
|
||||||
isDisabled={option.isDisabled}
|
isDisabled={option.isDisabled}
|
||||||
/>
|
>
|
||||||
|
{option.label}
|
||||||
|
</FormSelectOption>
|
||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useCallback } from 'react';
|
import React, { useEffect, useRef, useCallback } from 'react';
|
||||||
import { oneOf, bool, number, string, func, oneOfType } from 'prop-types';
|
import { oneOf, bool, number, string, func, oneOfType } from 'prop-types';
|
||||||
import { config } from 'ace-builds';
|
|
||||||
|
|
||||||
import ReactAce from 'react-ace';
|
import ReactAce from 'react-ace';
|
||||||
import 'ace-builds/src-noconflict/mode-json';
|
import 'ace-builds/src-noconflict/mode-json';
|
||||||
@@ -13,8 +12,6 @@ import { t } from '@lingui/macro';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import debounce from 'util/debounce';
|
import debounce from 'util/debounce';
|
||||||
|
|
||||||
config.set('loadWorkerFromBlob', false);
|
|
||||||
|
|
||||||
const LINE_HEIGHT = 24;
|
const LINE_HEIGHT = 24;
|
||||||
const PADDING = 12;
|
const PADDING = 12;
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function VariablesDetail({
|
|||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
css="color: var(--pf-global--danger-color--100);
|
css="color: var(--pf-global--danger-color--100);
|
||||||
font-size: var(--pf-global--FontSize--sm"
|
font-size: var(--pf-global--FontSize--sm)"
|
||||||
>
|
>
|
||||||
{t`Error:`} {error.message}
|
{t`Error:`} {error.message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
ouiaId="health-check"
|
ouiaId="health-check"
|
||||||
>
|
>
|
||||||
{t`Health Check`}
|
{t`Run health check`}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -42,7 +42,7 @@ function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
ouiaId="health-check"
|
ouiaId="health-check"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>{t`Health Check`}</Button>
|
>{t`Run health check`}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import AlertModal from '../AlertModal';
|
|||||||
import ErrorDetail from '../ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
|
|
||||||
function JobCancelButton({
|
function JobCancelButton({
|
||||||
job = {},
|
|
||||||
errorTitle,
|
errorTitle,
|
||||||
title,
|
title,
|
||||||
showIconButton,
|
showIconButton,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
buttonText,
|
buttonText,
|
||||||
|
style = {},
|
||||||
|
job = {},
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { error: cancelError, request: cancelJob } = useRequest(
|
const { error: cancelError, request: cancelJob } = useRequest(
|
||||||
@@ -38,6 +39,7 @@ function JobCancelButton({
|
|||||||
ouiaId="cancel-job-button"
|
ouiaId="cancel-job-button"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
<MinusCircleIcon />
|
<MinusCircleIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -48,6 +50,7 @@ function JobCancelButton({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
ouiaId="cancel-job-button"
|
ouiaId="cancel-job-button"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
{buttonText || t`Cancel Job`}
|
{buttonText || t`Cancel Job`}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -113,48 +113,6 @@ describe('LaunchButton', () => {
|
|||||||
expect(history.location.pathname).toEqual('/jobs/9000/output');
|
expect(history.location.pathname).toEqual('/jobs/9000/output');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should disable button to prevent duplicate clicks', async () => {
|
|
||||||
WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
can_start_without_user_input: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const history = createMemoryHistory({
|
|
||||||
initialEntries: ['/jobs/9000'],
|
|
||||||
});
|
|
||||||
WorkflowJobTemplatesAPI.launch.mockImplementation(async () => {
|
|
||||||
// return asynchronously so isLaunching isn't set back to false in the
|
|
||||||
// same tick
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
id: 9000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<LaunchButton
|
|
||||||
resource={{
|
|
||||||
id: 1,
|
|
||||||
type: 'workflow_job_template',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ handleLaunch, isLaunching }) => (
|
|
||||||
<button type="submit" onClick={handleLaunch} disabled={isLaunching} />
|
|
||||||
)}
|
|
||||||
</LaunchButton>,
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
router: { history },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const button = wrapper.find('button');
|
|
||||||
await act(() => button.prop('onClick')());
|
|
||||||
wrapper.update();
|
|
||||||
expect(wrapper.find('button').prop('disabled')).toEqual(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should relaunch job correctly', async () => {
|
test('should relaunch job correctly', async () => {
|
||||||
JobsAPI.readRelaunch.mockResolvedValue({
|
JobsAPI.readRelaunch.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TagMultiSelect } from '../../MultiSelect';
|
|||||||
import AnsibleSelect from '../../AnsibleSelect';
|
import AnsibleSelect from '../../AnsibleSelect';
|
||||||
import { VariablesField } from '../../CodeEditor';
|
import { VariablesField } from '../../CodeEditor';
|
||||||
import Popover from '../../Popover';
|
import Popover from '../../Popover';
|
||||||
|
import { VerbositySelectField } from '../../VerbositySelectField';
|
||||||
|
|
||||||
const FieldHeader = styled.div`
|
const FieldHeader = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -57,7 +58,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
|
|||||||
aria-label={t`Job Tags`}
|
aria-label={t`Job Tags`}
|
||||||
tooltip={t`Tags are useful when you have a large
|
tooltip={t`Tags are useful when you have a large
|
||||||
playbook, and you want to run a specific part of a play or task.
|
playbook, and you want to run a specific part of a play or task.
|
||||||
Use commas to separate multiple tags. Refer to Ansible Tower
|
Use commas to separate multiple tags. Refer to Ansible Controller
|
||||||
documentation for details on the usage of tags.`}
|
documentation for details on the usage of tags.`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -69,7 +70,7 @@ function OtherPromptsStep({ launchConfig, variablesMode, onVarModeChange }) {
|
|||||||
aria-label={t`Skip Tags`}
|
aria-label={t`Skip Tags`}
|
||||||
tooltip={t`Skip tags are useful when you have a large
|
tooltip={t`Skip tags are useful when you have a large
|
||||||
playbook, and you want to skip specific parts of a play or task.
|
playbook, and you want to skip specific parts of a play or task.
|
||||||
Use commas to separate multiple tags. Refer to Ansible Tower
|
Use commas to separate multiple tags. Refer to Ansible Controller
|
||||||
documentation for details on the usage of tags.`}
|
documentation for details on the usage of tags.`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -129,36 +130,16 @@ function JobTypeField() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VerbosityField() {
|
function VerbosityField() {
|
||||||
const [field, meta, helpers] = useField('verbosity');
|
const [, meta] = useField('verbosity');
|
||||||
const options = [
|
|
||||||
{ value: '0', key: '0', label: t`0 (Normal)` },
|
|
||||||
{ value: '1', key: '1', label: t`1 (Verbose)` },
|
|
||||||
{ value: '2', key: '2', label: t`2 (More Verbose)` },
|
|
||||||
{ value: '3', key: '3', label: t`3 (Debug)` },
|
|
||||||
{ value: '4', key: '4', label: t`4 (Connection Debug)` },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isValid = !(meta.touched && meta.error);
|
const isValid = !(meta.touched && meta.error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<VerbositySelectField
|
||||||
fieldId="prompt-verbosity"
|
fieldId="prompt-verbosity"
|
||||||
validated={isValid ? 'default' : 'error'}
|
tooltip={t`Control the level of output ansible
|
||||||
label={t`Verbosity`}
|
|
||||||
labelIcon={
|
|
||||||
<Popover
|
|
||||||
content={t`Control the level of output ansible
|
|
||||||
will produce as the playbook executes.`}
|
will produce as the playbook executes.`}
|
||||||
/>
|
isValid={isValid ? 'default' : 'error'}
|
||||||
}
|
/>
|
||||||
>
|
|
||||||
<AnsibleSelect
|
|
||||||
id="prompt-verbosity"
|
|
||||||
data={options}
|
|
||||||
{...field}
|
|
||||||
onChange={(event, value) => helpers.setValue(value)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe('OtherPromptsStep', () => {
|
|||||||
expect(wrapper.find('VerbosityField')).toHaveLength(1);
|
expect(wrapper.find('VerbosityField')).toHaveLength(1);
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('VerbosityField AnsibleSelect').prop('data')
|
wrapper.find('VerbosityField AnsibleSelect').prop('data')
|
||||||
).toHaveLength(5);
|
).toHaveLength(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render show changes toggle', async () => {
|
test('should render show changes toggle', async () => {
|
||||||
|
|||||||
@@ -40,13 +40,11 @@ function PreviewStep({ resource, launchConfig, surveyConfig, formErrors }) {
|
|||||||
.filter((q) => q.type === 'password')
|
.filter((q) => q.type === 'password')
|
||||||
.map((q) => q.variable);
|
.map((q) => q.variable);
|
||||||
const masked = maskPasswords(surveyValues, passwordFields);
|
const masked = maskPasswords(surveyValues, passwordFields);
|
||||||
overrides.extra_vars = yaml.safeDump(
|
overrides.extra_vars = yaml.dump(
|
||||||
mergeExtraVars(initialExtraVars, masked)
|
mergeExtraVars(initialExtraVars, masked)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
overrides.extra_vars = yaml.safeDump(
|
overrides.extra_vars = yaml.dump(mergeExtraVars(initialExtraVars, {}));
|
||||||
mergeExtraVars(initialExtraVars, {})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function MultipleChoiceField({ question }) {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
selections={field.value}
|
selections={field.value}
|
||||||
variant={SelectVariant.single}
|
variant={SelectVariant.typeahead}
|
||||||
id={id}
|
id={id}
|
||||||
ouiaId={`single-survey-question-${question.variable}`}
|
ouiaId={`single-survey-question-${question.variable}`}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user