mirror of
https://github.com/ansible/awx.git
synced 2026-02-05 11:34:43 -03:30
Compare commits
485 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b23856f126 | ||
|
|
c062728359 | ||
|
|
85d185cc8b | ||
|
|
77b8f345ae | ||
|
|
0052967aee | ||
|
|
63e9aed601 | ||
|
|
d4be8c8168 | ||
|
|
cdf4b0d1ed | ||
|
|
c43a59e475 | ||
|
|
014520ee2b | ||
|
|
c1abc56753 | ||
|
|
c5b4681bf4 | ||
|
|
00b7d6571a | ||
|
|
9ed2534ac5 | ||
|
|
a3bc3986bb | ||
|
|
230933744c | ||
|
|
227a90006e | ||
|
|
112f89660b | ||
|
|
a0910eb6de | ||
|
|
5433af6716 | ||
|
|
9744b89737 | ||
|
|
04c535e3f9 | ||
|
|
259e53f59d | ||
|
|
ac9bf1afcf | ||
|
|
4b62d77015 | ||
|
|
ef5ce0b082 | ||
|
|
1942be7dc3 | ||
|
|
210f9577b0 | ||
|
|
87a05a5b2e | ||
|
|
f8a754cf44 | ||
|
|
3ea37e1c79 | ||
|
|
c997fcfc2c | ||
|
|
4dd4928aab | ||
|
|
5aeaabaceb | ||
|
|
0d4e6d7e0b | ||
|
|
9ae038868c | ||
|
|
0b4ae74698 | ||
|
|
0d248a12bc | ||
|
|
7396e2e7ac | ||
|
|
cac3bece56 | ||
|
|
e4145b580c | ||
|
|
74076b99d6 | ||
|
|
5d35506b0c | ||
|
|
e646b46a2c | ||
|
|
475e2605d4 | ||
|
|
c16ad89ff9 | ||
|
|
425d1168b9 | ||
|
|
7ceaa9ec4a | ||
|
|
4b3d3537b4 | ||
|
|
efbff24528 | ||
|
|
1d9ce6cc15 | ||
|
|
794ce96b17 | ||
|
|
181421a2ee | ||
|
|
9c9496a683 | ||
|
|
2b111c81df | ||
|
|
f467e26842 | ||
|
|
7700050d10 | ||
|
|
a8d34b46fb | ||
|
|
bf6c16197c | ||
|
|
25cc341888 | ||
|
|
d899e75ad7 | ||
|
|
732da52239 | ||
|
|
ab2f212b04 | ||
|
|
f94438cf9b | ||
|
|
2569ec4f4f | ||
|
|
b58bff4686 | ||
|
|
6fab3590ae | ||
|
|
846fd67618 | ||
|
|
6254129f0d | ||
|
|
3409d39150 | ||
|
|
9de165a676 | ||
|
|
f54616912d | ||
|
|
c003e89ea9 | ||
|
|
6e64b5c070 | ||
|
|
fcfc34fef1 | ||
|
|
75b7d74f91 | ||
|
|
569b5bc533 | ||
|
|
9ab9c6961b | ||
|
|
2e525f8922 | ||
|
|
9c6300c2de | ||
|
|
f8153393b1 | ||
|
|
cb07e9c757 | ||
|
|
639b297027 | ||
|
|
4341d67fb0 | ||
|
|
6260633974 | ||
|
|
8ec856f3b6 | ||
|
|
5a207f155e | ||
|
|
2a722ba8d0 | ||
|
|
efbd2177a5 | ||
|
|
989e1ca5d6 | ||
|
|
4c89568d71 | ||
|
|
a9688ac805 | ||
|
|
12a8793ddb | ||
|
|
7bbf640389 | ||
|
|
cb6688c685 | ||
|
|
266a4e71c5 | ||
|
|
c29afce54d | ||
|
|
eddee456b3 | ||
|
|
be5a12a318 | ||
|
|
e131e8c151 | ||
|
|
d30ecaa7e3 | ||
|
|
dfc4a0c0e0 | ||
|
|
86ba1639c3 | ||
|
|
f1b4e24833 | ||
|
|
35d36a71c7 | ||
|
|
eadcbe1ce9 | ||
|
|
f0198105c4 | ||
|
|
e34c7acdc4 | ||
|
|
001d469bd0 | ||
|
|
a9e5981cfe | ||
|
|
b36b6978fb | ||
|
|
55a19ffe6a | ||
|
|
c4d358b870 | ||
|
|
5ae7df7757 | ||
|
|
220168f5ee | ||
|
|
3cc9139c6d | ||
|
|
01161c7afd | ||
|
|
6d595cbda6 | ||
|
|
419d32d3e3 | ||
|
|
334c63388b | ||
|
|
32f6f87463 | ||
|
|
0d92b2e703 | ||
|
|
bc6d879976 | ||
|
|
9bae9d32c7 | ||
|
|
b5724adae5 | ||
|
|
1048baa98c | ||
|
|
922ea67541 | ||
|
|
3d105e3b7a | ||
|
|
aceef98601 | ||
|
|
d41322c63c | ||
|
|
d6e5eb356b | ||
|
|
b46a2b43b0 | ||
|
|
6f54044cc6 | ||
|
|
5d1f322cd1 | ||
|
|
c11a8b8ae1 | ||
|
|
2d4df3d50e | ||
|
|
715483c669 | ||
|
|
30f65f38a7 | ||
|
|
aaf093b0e0 | ||
|
|
bd7248d21c | ||
|
|
9bdd49bec5 | ||
|
|
2506db88f2 | ||
|
|
61c38eabf8 | ||
|
|
37a1e5d9b0 | ||
|
|
c439a1ec8f | ||
|
|
a1d110aac7 | ||
|
|
c5e22f9aa3 | ||
|
|
6dc5f91a0f | ||
|
|
8a9ebe2086 | ||
|
|
a859ecfbde | ||
|
|
02fd26520d | ||
|
|
f8b2bcbae7 | ||
|
|
f5157784c4 | ||
|
|
93b49f314d | ||
|
|
55d81cf74d | ||
|
|
f629822596 | ||
|
|
bf2a4d1a2c | ||
|
|
afadfa939d | ||
|
|
02c3e1c32f | ||
|
|
a0d20a5d50 | ||
|
|
b8d27d53b8 | ||
|
|
878659cded | ||
|
|
027ce7fbdb | ||
|
|
540f8ab7d6 | ||
|
|
0362c88e48 | ||
|
|
129374a1c2 | ||
|
|
63fd546f44 | ||
|
|
9856c9154e | ||
|
|
e7a712394a | ||
|
|
208e36f83b | ||
|
|
68a6984fcd | ||
|
|
a90e0e8834 | ||
|
|
8ab6a79b37 | ||
|
|
e68d576fd2 | ||
|
|
ca247182df | ||
|
|
1f628778bb | ||
|
|
dcbb2813b5 | ||
|
|
9cdb281f06 | ||
|
|
8116ec8e1f | ||
|
|
c373420982 | ||
|
|
93a9a0354f | ||
|
|
ee6e28e066 | ||
|
|
ea5d429399 | ||
|
|
3b49dd78bf | ||
|
|
42b019d8c8 | ||
|
|
a1af4e1808 | ||
|
|
ffdcb2f8eb | ||
|
|
7b5f4f51fb | ||
|
|
25c2b9610a | ||
|
|
5935583c4c | ||
|
|
68f17eb370 | ||
|
|
1ad8a49155 | ||
|
|
47ed5ef848 | ||
|
|
a56686ca77 | ||
|
|
211786976d | ||
|
|
7e82f0fad7 | ||
|
|
8612bf79e8 | ||
|
|
78edf51803 | ||
|
|
632810f3a8 | ||
|
|
695eab1fdd | ||
|
|
081a0fc04e | ||
|
|
48f10669d6 | ||
|
|
4f8b624b96 | ||
|
|
c87c0aa712 | ||
|
|
05e6f4ab3c | ||
|
|
1a85874964 | ||
|
|
6f2224c8e5 | ||
|
|
57e155f0f9 | ||
|
|
a6924c1bcf | ||
|
|
4acb28f6f5 | ||
|
|
3ed5d6ec65 | ||
|
|
15bcea7301 | ||
|
|
ce8c0066d0 | ||
|
|
bdd63f36a8 | ||
|
|
24abc1462f | ||
|
|
12363ae175 | ||
|
|
1b50895738 | ||
|
|
1fbae00e37 | ||
|
|
b10a71786b | ||
|
|
0d659b0111 | ||
|
|
deb8714987 | ||
|
|
ee8775a08d | ||
|
|
31650bb0bd | ||
|
|
cbf085ab43 | ||
|
|
78d715efed | ||
|
|
2cb5b0563b | ||
|
|
0eb55f5038 | ||
|
|
daf3bbc7ef | ||
|
|
caa6d0c4d3 | ||
|
|
bc7ae4ca46 | ||
|
|
db2316b791 | ||
|
|
b7efd5a9ab | ||
|
|
83caf99c58 | ||
|
|
285fb2582e | ||
|
|
19180a1bc4 | ||
|
|
9c86f521e9 | ||
|
|
2171823846 | ||
|
|
c4143b0111 | ||
|
|
94fa4deab3 | ||
|
|
53aadd3b96 | ||
|
|
faa0802d97 | ||
|
|
fa144aa98f | ||
|
|
ea4e98c52a | ||
|
|
8ff413efc0 | ||
|
|
804a3c17bf | ||
|
|
da5eb710cd | ||
|
|
57f9b31b2b | ||
|
|
34ba858e3b | ||
|
|
5e24cee0ae | ||
|
|
a026838f77 | ||
|
|
e2cd86089b | ||
|
|
85d5387f31 | ||
|
|
cf13a1b70a | ||
|
|
fed6a86170 | ||
|
|
c8907fb39d | ||
|
|
162e4aeec4 | ||
|
|
c6d2fa86c7 | ||
|
|
f89db3586b | ||
|
|
19742859b6 | ||
|
|
eff46dbc71 | ||
|
|
80b75a163a | ||
|
|
fe65073f3e | ||
|
|
6f2b10daf5 | ||
|
|
ad3d89afd3 | ||
|
|
e3c2c310ef | ||
|
|
c574cdc7dc | ||
|
|
097b59e74a | ||
|
|
8e7d607a47 | ||
|
|
4c32faa448 | ||
|
|
17509d560d | ||
|
|
7b1b656455 | ||
|
|
268b22c550 | ||
|
|
b525d0a6f4 | ||
|
|
c7cabfa785 | ||
|
|
9c2797b34c | ||
|
|
732f7d2292 | ||
|
|
f5fc0871fc | ||
|
|
9458741b72 | ||
|
|
a1f7f967e3 | ||
|
|
91c78d7137 | ||
|
|
b88f4ce27c | ||
|
|
e8606d9478 | ||
|
|
90d38a50de | ||
|
|
a83164cca6 | ||
|
|
e1e7e9047d | ||
|
|
094eef635d | ||
|
|
56bb82e303 | ||
|
|
0290dd3246 | ||
|
|
de8c46cab0 | ||
|
|
9028a48ab2 | ||
|
|
709fa74070 | ||
|
|
5342faa997 | ||
|
|
f0865d69f0 | ||
|
|
ddf9fd581e | ||
|
|
b6745db4b8 | ||
|
|
35a565d09f | ||
|
|
b878aed400 | ||
|
|
1961a8ba15 | ||
|
|
b76018d6e0 | ||
|
|
a40398e6a1 | ||
|
|
97e2fbbe27 | ||
|
|
bcbad06c10 | ||
|
|
1c74773eac | ||
|
|
9701ac1804 | ||
|
|
3d90c6dfcf | ||
|
|
1402a2c8a5 | ||
|
|
6567ad612c | ||
|
|
a15bf9ee41 | ||
|
|
da448f6a0b | ||
|
|
513f54a422 | ||
|
|
05d9220b21 | ||
|
|
9bb9bc682f | ||
|
|
1d6f116687 | ||
|
|
9a9d53d17a | ||
|
|
755ffc9844 | ||
|
|
0ffbb06427 | ||
|
|
c13c5b6c13 | ||
|
|
70979df36a | ||
|
|
83ee39cabd | ||
|
|
b0d31a64aa | ||
|
|
06c53c14be | ||
|
|
a63778e40e | ||
|
|
6f38edf9a3 | ||
|
|
1f05372ac9 | ||
|
|
d0327fc044 | ||
|
|
068dab14d4 | ||
|
|
f64d0dde5a | ||
|
|
7cc0041aa8 | ||
|
|
f66f24eb83 | ||
|
|
e3ee3c5a00 | ||
|
|
1198c067b2 | ||
|
|
d3ea09d60c | ||
|
|
c0abb063f9 | ||
|
|
95cdddd670 | ||
|
|
d91aa8c6cf | ||
|
|
052f101a70 | ||
|
|
c96e88877f | ||
|
|
1564dfc80f | ||
|
|
b0cb3ca9da | ||
|
|
84b5fb89a3 | ||
|
|
5319659d58 | ||
|
|
5d27c28b47 | ||
|
|
68a6315626 | ||
|
|
8bfbd85cf9 | ||
|
|
f7b6d9fdff | ||
|
|
ab4fba7ce9 | ||
|
|
deb6e58397 | ||
|
|
4746bc7c09 | ||
|
|
823a74c98b | ||
|
|
c294a63f32 | ||
|
|
84bce530dc | ||
|
|
6acd3c98b7 | ||
|
|
1e80b2e295 | ||
|
|
e4721d7722 | ||
|
|
0cea8121bb | ||
|
|
eaac54040c | ||
|
|
763ac25b2e | ||
|
|
922723cf39 | ||
|
|
f216c8f90f | ||
|
|
4e31bdd2d2 | ||
|
|
d5e9716ceb | ||
|
|
01963b0ee7 | ||
|
|
a353f2a807 | ||
|
|
69205c5f6b | ||
|
|
941bba2ae0 | ||
|
|
ddccfaa6fe | ||
|
|
24da2b78b8 | ||
|
|
6fee0db17b | ||
|
|
b25fbc5266 | ||
|
|
e6235a4046 | ||
|
|
0f32161df0 | ||
|
|
b570c8ad2a | ||
|
|
9170aa184a | ||
|
|
68c26014cc | ||
|
|
f049b61460 | ||
|
|
458ca69405 | ||
|
|
8a4c85e473 | ||
|
|
09d883f94a | ||
|
|
9ef57ec510 | ||
|
|
5be006f9d3 | ||
|
|
089bafa5d4 | ||
|
|
fa278f83ad | ||
|
|
0d68ca8f14 | ||
|
|
2ec90f17d0 | ||
|
|
ecf340f722 | ||
|
|
713079bd70 | ||
|
|
d77040a7a9 | ||
|
|
d3b137fbc4 | ||
|
|
857faf570d | ||
|
|
5246c842b2 | ||
|
|
1dca4c9098 | ||
|
|
8cb32045f0 | ||
|
|
4962b729de | ||
|
|
ed39a127e7 | ||
|
|
c4b4a4c21a | ||
|
|
bd81fda05c | ||
|
|
83550eeba0 | ||
|
|
4540cb653e | ||
|
|
69597c5654 | ||
|
|
fa61aef194 | ||
|
|
871d87374b | ||
|
|
e35f6b2acb | ||
|
|
b3e056fe55 | ||
|
|
a8140e86d7 | ||
|
|
e5b76c6427 | ||
|
|
4d4ae84e32 | ||
|
|
ae349addfe | ||
|
|
31fdd5e85c | ||
|
|
e4bde24f38 | ||
|
|
9c019e1cc0 | ||
|
|
b3d298269b | ||
|
|
21f7ca21e0 | ||
|
|
43bf370f8c | ||
|
|
6057921e34 | ||
|
|
5095816762 | ||
|
|
24eae09ed9 | ||
|
|
a2fee252f9 | ||
|
|
ab80c2276d | ||
|
|
f78c9f357d | ||
|
|
da1e43dc12 | ||
|
|
c1da74cbc0 | ||
|
|
cb354c2ef1 | ||
|
|
4d0c567d73 | ||
|
|
b48815d2bb | ||
|
|
ad383cdb44 | ||
|
|
91afa88b44 | ||
|
|
b575fa4243 | ||
|
|
3cab73c574 | ||
|
|
b3af64d66f | ||
|
|
1869b73826 | ||
|
|
cc47afa856 | ||
|
|
841975d72b | ||
|
|
678ce81487 | ||
|
|
69e0f858bc | ||
|
|
2b12e26b98 | ||
|
|
634550fb0b | ||
|
|
dc89479b4c | ||
|
|
b51b1a959f | ||
|
|
8de92b152c | ||
|
|
95ab5327c3 | ||
|
|
e40f29092b | ||
|
|
b394862210 | ||
|
|
0434c611f0 | ||
|
|
201ae5f948 | ||
|
|
9d93b78296 | ||
|
|
8669e87454 | ||
|
|
b2557c6fd8 | ||
|
|
8f5d25a5df | ||
|
|
4c199b0ab2 | ||
|
|
ef7b3fec94 | ||
|
|
93bd1e6705 | ||
|
|
b13009b9a3 | ||
|
|
fc941eda98 | ||
|
|
32deca2e92 | ||
|
|
ff1a618a93 | ||
|
|
0af79b729e | ||
|
|
76711febd1 | ||
|
|
81e545b720 | ||
|
|
d985b1215a | ||
|
|
157bec1777 | ||
|
|
1754076a56 | ||
|
|
d3132820a5 | ||
|
|
9f4d65891c | ||
|
|
653ec0ffab | ||
|
|
28228a3b57 | ||
|
|
e2470200da | ||
|
|
cfd7946097 | ||
|
|
fb67b8edf9 | ||
|
|
7af2bcc9b0 | ||
|
|
8e83c86d88 | ||
|
|
53cf6cf17c | ||
|
|
8701f83922 | ||
|
|
7344ee23ef | ||
|
|
a0bf3459eb | ||
|
|
facec0fe76 | ||
|
|
6fb09d73b1 | ||
|
|
a3519ce1df | ||
|
|
812d00f490 | ||
|
|
5ac2211ef4 | ||
|
|
9c9bf0ed84 | ||
|
|
c013d656c8 | ||
|
|
e38ed6574c | ||
|
|
267e297eca | ||
|
|
a733a59b8d | ||
|
|
1f76a88656 |
1
.github/BOTMETA.yml
vendored
1
.github/BOTMETA.yml
vendored
@@ -1,3 +1,4 @@
|
||||
---
|
||||
files:
|
||||
awx/ui/:
|
||||
labels: component:ui
|
||||
|
||||
12
.yamllint
Normal file
12
.yamllint
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
ignore: |
|
||||
.tox
|
||||
awx/main/tests/data/inventory/plugins/**
|
||||
# vault files
|
||||
awx/main/tests/data/ansible_utils/playbooks/valid/vault.yml
|
||||
awx/ui/test/e2e/tests/smoke-vars.yml
|
||||
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
line-length: disable
|
||||
133
INSTALL.md
133
INSTALL.md
@@ -4,41 +4,45 @@ This document provides a guide for installing AWX.
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Getting started](#getting-started)
|
||||
- [Clone the repo](#clone-the-repo)
|
||||
- [AWX branding](#awx-branding)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [System Requirements](#system-requirements)
|
||||
- [AWX Tunables](#awx-tunables)
|
||||
- [Choose a deployment platform](#choose-a-deployment-platform)
|
||||
- [Official vs Building Images](#official-vs-building-images)
|
||||
- [OpenShift](#openshift)
|
||||
- [Prerequisites](#prerequisites-1)
|
||||
- [Deploying to Minishift](#deploying-to-minishift)
|
||||
- [Pre-build steps](#pre-build-steps)
|
||||
- [PostgreSQL](#postgresql)
|
||||
- [Start the build](#start-the-build)
|
||||
- [Post build](#post-build)
|
||||
- [Accessing AWX](#accessing-awx)
|
||||
- [Kubernetes](#kubernetes)
|
||||
- [Prerequisites](#prerequisites-2)
|
||||
- [Pre-build steps](#pre-build-steps-1)
|
||||
- [Configuring Helm](#configuring-helm)
|
||||
- [Start the build](#start-the-build-1)
|
||||
- [Accessing AWX](#accessing-awx-1)
|
||||
- [SSL Termination](#ssl-termination)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Prerequisites](#prerequisites-3)
|
||||
- [Pre-build steps](#pre-build-steps-2)
|
||||
- [Deploying to a remote host](#deploying-to-a-remote-host)
|
||||
- [Inventory variables](#inventory-variables)
|
||||
- [Installing AWX](#installing-awx)
|
||||
* [Getting started](#getting-started)
|
||||
+ [Clone the repo](#clone-the-repo)
|
||||
+ [AWX branding](#awx-branding)
|
||||
+ [Prerequisites](#prerequisites)
|
||||
+ [System Requirements](#system-requirements)
|
||||
+ [AWX Tunables](#awx-tunables)
|
||||
+ [Choose a deployment platform](#choose-a-deployment-platform)
|
||||
+ [Official vs Building Images](#official-vs-building-images)
|
||||
* [Upgrading from previous versions](#upgrading-from-previous-versions)
|
||||
* [OpenShift](#openshift)
|
||||
+ [Prerequisites](#prerequisites-1)
|
||||
+ [Pre-install steps](#pre-install-steps)
|
||||
- [Deploying to Minishift](#deploying-to-minishift)
|
||||
- [PostgreSQL](#postgresql)
|
||||
+ [Run the installer](#run-the-installer)
|
||||
+ [Post-install](#post-install)
|
||||
+ [Accessing AWX](#accessing-awx)
|
||||
* [Kubernetes](#kubernetes)
|
||||
+ [Prerequisites](#prerequisites-2)
|
||||
+ [Pre-install steps](#pre-install-steps-1)
|
||||
+ [Configuring Helm](#configuring-helm)
|
||||
+ [Run the installer](#run-the-installer-1)
|
||||
+ [Post-install](#post-install-1)
|
||||
+ [Accessing AWX](#accessing-awx-1)
|
||||
+ [SSL Termination](#ssl-termination)
|
||||
* [Docker-Compose](#docker-compose)
|
||||
+ [Prerequisites](#prerequisites-3)
|
||||
+ [Pre-install steps](#pre-install-steps-2)
|
||||
- [Deploying to a remote host](#deploying-to-a-remote-host)
|
||||
- [Inventory variables](#inventory-variables)
|
||||
- [Docker registry](#docker-registry)
|
||||
- [PostgreSQL](#postgresql-1)
|
||||
- [Proxy settings](#proxy-settings)
|
||||
- [Start the build](#start-the-build-2)
|
||||
- [Post build](#post-build-2)
|
||||
- [Accessing AWX](#accessing-awx-2)
|
||||
- [PostgreSQL](#postgresql-1)
|
||||
+ [Run the installer](#run-the-installer-2)
|
||||
+ [Post-install](#post-install-2)
|
||||
+ [Accessing AWX](#accessing-awx-2)
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
### Clone the repo
|
||||
@@ -57,7 +61,7 @@ To install the assets, clone the `awx-logos` repo so that it is next to your `aw
|
||||
|
||||
Before you can run a deployment, you'll need the following installed in your local environment:
|
||||
|
||||
- [Ansible](http://docs.ansible.com/ansible/latest/intro_installation.html) Requires Version 2.4+
|
||||
- [Ansible](http://docs.ansible.com/ansible/latest/intro_installation.html) Requires Version 2.8+
|
||||
- [Docker](https://docs.docker.com/engine/installation/)
|
||||
+ A recent version
|
||||
- [docker](https://pypi.org/project/docker/) Python module
|
||||
@@ -114,6 +118,26 @@ If these variables are present then all deployments will use these hosted images
|
||||
|
||||
> Multiple versions are provided. `latest` always pulls the most recent. You may also select version numbers at different granularities: 1, 1.0, 1.0.1, 1.0.0.123
|
||||
|
||||
|
||||
## Upgrading from previous versions
|
||||
|
||||
Upgrading AWX involves rerunning the install playbook. Download a newer release from [https://github.com/ansible/awx/releases](https://github.com/ansible/awx/releases) and re-populate the inventory file with your customized variables.
|
||||
|
||||
For convenience, you can create a file called `vars.yml`:
|
||||
|
||||
```
|
||||
admin_password: 'adminpass'
|
||||
pg_password: 'pgpass'
|
||||
rabbitmq_password: 'rabbitpass'
|
||||
secret_key: 'mysupersecret'
|
||||
```
|
||||
|
||||
And pass it to the installer:
|
||||
|
||||
```
|
||||
$ ansible-playbook -i inventory install.yml -e @vars.yml
|
||||
```
|
||||
|
||||
## OpenShift
|
||||
|
||||
### Prerequisites
|
||||
@@ -133,9 +157,9 @@ This can be tuned by overriding the variables found in [/installer/roles/kuberne
|
||||
|
||||
For more detail on how resource requests are formed see: [https://docs.openshift.com/container-platform/latest/dev_guide/compute_resources.html#dev-compute-resources](https://docs.openshift.com/container-platform/latest/dev_guide/compute_resources.html#dev-compute-resources)
|
||||
|
||||
### Pre-build steps
|
||||
### Pre-install steps
|
||||
|
||||
Before starting the build process, review the [inventory](./installer/inventory) file, and uncomment and provide values for the following variables found in the `[all:vars]` section:
|
||||
Before starting the install, review the [inventory](./installer/inventory) file, and uncomment and provide values for the following variables found in the `[all:vars]` section:
|
||||
|
||||
*openshift_host*
|
||||
|
||||
@@ -197,20 +221,20 @@ By default, AWX will deploy a PostgreSQL pod inside of your cluster. You will ne
|
||||
|
||||
If you wish to use an external database, in the inventory file, set the value of `pg_hostname`, and update `pg_username`, `pg_password`, `pg_admin_password`, `pg_database`, and `pg_port` with the connection information. When setting `pg_hostname` the installer will assume you have configured the database in that location and will not launch the postgresql pod.
|
||||
|
||||
### Start the build
|
||||
### Run the installer
|
||||
|
||||
To start the build, you will pass two *extra* variables on the command line. The first is *openshift_password*, which is the password for the *openshift_user*, and the second is *docker_registry_password*, which is the password associated with *docker_registry_username*.
|
||||
To start the install, you will pass two *extra* variables on the command line. The first is *openshift_password*, which is the password for the *openshift_user*, and the second is *docker_registry_password*, which is the password associated with *docker_registry_username*.
|
||||
|
||||
If you're using the OpenShift internal registry, then you'll pass an access token for the *docker_registry_password* value, rather than a password. The `oc whoami -t` command will generate the required token, as long as you're logged into the cluster via `oc cluster login`.
|
||||
|
||||
To start the build and deployment, run the following (docker_registry_password is optional if using official images):
|
||||
Run the following command (docker_registry_password is optional if using official images):
|
||||
|
||||
```bash
|
||||
# Start the build and deployment
|
||||
# Start the install
|
||||
$ ansible-playbook -i inventory install.yml -e openshift_password=developer -e docker_registry_password=$(oc whoami -t)
|
||||
```
|
||||
|
||||
### Post build
|
||||
### Post-install
|
||||
|
||||
After the playbook run completes, check the status of the deployment by running `oc get pods`:
|
||||
|
||||
@@ -327,9 +351,9 @@ This can be tuned by overriding the variables found in [/installer/roles/kuberne
|
||||
|
||||
For more detail on how resource requests are formed see: [https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/](https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/)
|
||||
|
||||
### Pre-build steps
|
||||
### Pre-install steps
|
||||
|
||||
Before starting the build process, review the [inventory](./installer/inventory) file, and uncomment and provide values for the following variables found in the `[all:vars]` section uncommenting when necessary. Make sure the openshift and standalone docker sections are commented out:
|
||||
Before starting the install process, review the [inventory](./installer/inventory) file, and uncomment and provide values for the following variables found in the `[all:vars]` section uncommenting when necessary. Make sure the openshift and standalone docker sections are commented out:
|
||||
|
||||
*kubernetes_context*
|
||||
|
||||
@@ -349,7 +373,7 @@ If you want the AWX installer to manage creating the database pod (rather than i
|
||||
|
||||
Newer Kubernetes clusters with RBAC enabled will need to make sure a service account is created, make sure to follow the instructions here [https://docs.helm.sh/using_helm/#role-based-access-control](https://docs.helm.sh/using_helm/#role-based-access-control)
|
||||
|
||||
### Start the build
|
||||
### Run the installer
|
||||
|
||||
After making changes to the `inventory` file use `ansible-playbook` to begin the install
|
||||
|
||||
@@ -357,7 +381,7 @@ After making changes to the `inventory` file use `ansible-playbook` to begin the
|
||||
$ ansible-playbook -i inventory install.yml
|
||||
```
|
||||
|
||||
### Post build
|
||||
### Post-install
|
||||
|
||||
After the playbook run completes, check the status of the deployment by running `kubectl get pods --namespace awx` (replace awx with the namespace you used):
|
||||
|
||||
@@ -405,7 +429,7 @@ Unlike Openshift's `Route` the Kubernetes `Ingress` doesn't yet handle SSL termi
|
||||
+ This also installs the `docker` Python module, which is incompatible with `docker-py`. If you have previously installed `docker-py`, please uninstall it.
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
### Pre-build steps
|
||||
### Pre-install steps
|
||||
|
||||
#### Deploying to a remote host
|
||||
|
||||
@@ -436,7 +460,7 @@ If you choose to use the official images then the remote host will be the one to
|
||||
|
||||
#### Inventory variables
|
||||
|
||||
Before starting the build process, review the [inventory](./installer/inventory) file, and uncomment and provide values for the following variables found in the `[all:vars]` section:
|
||||
Before starting the install process, review the [inventory](./installer/inventory) file, and uncomment and provide values for the following variables found in the `[all:vars]` section:
|
||||
|
||||
*postgres_data_dir*
|
||||
|
||||
@@ -511,9 +535,9 @@ AWX requires access to a PostgreSQL database, and by default, one will be create
|
||||
|
||||
If you wish to use an external database, in the inventory file, set the value of `pg_hostname`, and update `pg_username`, `pg_password`, `pg_admin_password`, `pg_database`, and `pg_port` with the connection information.
|
||||
|
||||
### Start the build
|
||||
### Run the installer
|
||||
|
||||
If you are not pushing images to a Docker registry, start the build by running the following:
|
||||
If you are not pushing images to a Docker registry, start the install by running the following:
|
||||
|
||||
```bash
|
||||
# Set the working directory to installer
|
||||
@@ -533,7 +557,7 @@ $ cd installer
|
||||
$ ansible-playbook -i inventory -e docker_registry_password=password install.yml
|
||||
```
|
||||
|
||||
### Post build
|
||||
### Post-install
|
||||
|
||||
After the playbook run completes, Docker will report up to 5 running containers. If you chose to use an existing PostgresSQL database, then it will report 4. You can view the running containers using the `docker ps` command, as follows:
|
||||
|
||||
@@ -610,14 +634,3 @@ Added instance awx to tower
|
||||
The AWX web server is accessible on the deployment host, using the *host_port* value set in the *inventory* file. The default URL is [http://localhost](http://localhost).
|
||||
|
||||
You will prompted with a login dialog. The default administrator username is `admin`, and the password is `password`.
|
||||
|
||||
### Maintenance using docker-compose
|
||||
|
||||
After the installation, maintenance operations with docker-compose can be done by using the `docker-compose.yml` file created at the location pointed by `docker_compose_dir`.
|
||||
|
||||
Among the possible operations, you may:
|
||||
|
||||
- Stop AWX : `docker-compose stop`
|
||||
- Upgrade AWX : `docker-compose pull && docker-compose up --force-recreate`
|
||||
|
||||
See the [docker-compose documentation](https://docs.docker.com/compose/) for details.
|
||||
|
||||
38
Makefile
38
Makefile
@@ -100,7 +100,7 @@ clean-languages:
|
||||
find . -type f -regex ".*\.mo$$" -delete
|
||||
|
||||
# Remove temporary build files, compiled Python files.
|
||||
clean: clean-ui clean-api clean-dist
|
||||
clean: clean-ui clean-api clean-awxkit clean-dist
|
||||
rm -rf awx/public
|
||||
rm -rf awx/lib/site-packages
|
||||
rm -rf awx/job_status
|
||||
@@ -116,6 +116,10 @@ clean-api:
|
||||
find . -type d -name "__pycache__" -delete
|
||||
rm -f awx/awx_test.sqlite3*
|
||||
rm -rf requirements/vendor
|
||||
rm -rf awx/projects
|
||||
|
||||
clean-awxkit:
|
||||
rm -rf awxkit/*.egg-info awxkit/.tox
|
||||
|
||||
# convenience target to assert environment variables are defined
|
||||
guard-%:
|
||||
@@ -364,7 +368,7 @@ check: flake8 pep8 # pyflakes pylint
|
||||
|
||||
awx-link:
|
||||
cp -R /tmp/awx.egg-info /awx_devel/ || true
|
||||
sed -i "s/placeholder/$(shell git describe --long | sed 's/\./\\./g')/" /awx_devel/awx.egg-info/PKG-INFO
|
||||
sed -i "s/placeholder/$(shell cat VERSION)/" /awx_devel/awx.egg-info/PKG-INFO
|
||||
cp -f /tmp/awx.egg-link /venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link
|
||||
|
||||
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
|
||||
@@ -381,7 +385,6 @@ test:
|
||||
prepare_collection_venv:
|
||||
rm -rf $(COLLECTION_VENV)
|
||||
mkdir $(COLLECTION_VENV)
|
||||
ln -s /usr/lib/python2.7/site-packages/ansible $(COLLECTION_VENV)/ansible
|
||||
$(VENV_BASE)/awx/bin/pip install --target=$(COLLECTION_VENV) git+https://github.com/ansible/tower-cli.git
|
||||
|
||||
COLLECTION_TEST_DIRS ?= awx_collection/test/awx
|
||||
@@ -404,7 +407,7 @@ test_collection_sanity:
|
||||
mkdir -p sanity/ansible_collections/awx
|
||||
cp -Ra awx_collection sanity/ansible_collections/awx/awx # symlinks do not work
|
||||
cd sanity/ansible_collections/awx/awx && git init && git add . # requires both this file structure and a git repo, so there you go
|
||||
cd sanity/ansible_collections/awx/awx && ansible-test sanity --test validate-modules
|
||||
cd sanity/ansible_collections/awx/awx && ansible-test sanity
|
||||
|
||||
build_collection:
|
||||
ansible-playbook -i localhost, awx_collection/template_galaxy.yml -e collection_package=$(COLLECTION_PACKAGE) -e collection_namespace=$(COLLECTION_NAMESPACE) -e collection_version=$(VERSION)
|
||||
@@ -616,28 +619,34 @@ docker-auth:
|
||||
echo "$(IMAGE_REPOSITORY_AUTH)" | docker login -u oauth2accesstoken --password-stdin $(IMAGE_REPOSITORY_BASE); \
|
||||
fi;
|
||||
|
||||
# This directory is bind-mounted inside of the development container and
|
||||
# needs to be pre-created for permissions to be set correctly. Otherwise,
|
||||
# Docker will create this directory as root.
|
||||
awx/projects:
|
||||
@mkdir -p $@
|
||||
|
||||
# Docker isolated rampart
|
||||
docker-compose-isolated:
|
||||
docker-compose-isolated: awx/projects
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-isolated-override.yml up
|
||||
|
||||
# Docker Compose Development environment
|
||||
docker-compose: docker-auth
|
||||
docker-compose: docker-auth awx/projects
|
||||
CURRENT_UID=$(shell id -u) OS="$(shell docker info | grep 'Operating System')" TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml up --no-recreate awx
|
||||
|
||||
docker-compose-cluster: docker-auth
|
||||
docker-compose-cluster: docker-auth awx/projects
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml up
|
||||
|
||||
docker-compose-credential-plugins: docker-auth
|
||||
docker-compose-credential-plugins: docker-auth awx/projects
|
||||
echo -e "\033[0;31mTo generate a CyberArk Conjur API key: docker exec -it tools_conjur_1 conjurctl account create quick-start\033[0m"
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/docker-credential-plugins-override.yml up --no-recreate awx
|
||||
|
||||
docker-compose-test: docker-auth
|
||||
docker-compose-test: docker-auth awx/projects
|
||||
cd tools && CURRENT_UID=$(shell id -u) OS="$(shell docker info | grep 'Operating System')" TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /bin/bash
|
||||
|
||||
docker-compose-runtest:
|
||||
docker-compose-runtest: awx/projects
|
||||
cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /start_tests.sh
|
||||
|
||||
docker-compose-build-swagger:
|
||||
docker-compose-build-swagger: awx/projects
|
||||
cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm --service-ports awx /start_tests.sh swagger
|
||||
|
||||
detect-schema-change: genschema
|
||||
@@ -645,7 +654,7 @@ detect-schema-change: genschema
|
||||
# Ignore differences in whitespace with -b
|
||||
diff -u -b reference-schema.json schema.json
|
||||
|
||||
docker-compose-clean:
|
||||
docker-compose-clean: awx/projects
|
||||
cd tools && CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose run --rm -w /awx_devel --service-ports awx make clean
|
||||
cd tools && TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose rm -sf
|
||||
|
||||
@@ -654,7 +663,6 @@ docker-compose-build: awx-devel-build
|
||||
# Base development image build
|
||||
awx-devel-build:
|
||||
docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \
|
||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:devel \
|
||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) .
|
||||
docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
#docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||
@@ -674,10 +682,10 @@ docker-clean:
|
||||
docker-refresh: docker-clean docker-compose
|
||||
|
||||
# Docker Development Environment with Elastic Stack Connected
|
||||
docker-compose-elk: docker-auth
|
||||
docker-compose-elk: docker-auth awx/projects
|
||||
CURRENT_UID=$(shell id -u) TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose.yml -f tools/elastic/docker-compose.logstash-link.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||
|
||||
docker-compose-cluster-elk: docker-auth
|
||||
docker-compose-cluster-elk: docker-auth awx/projects
|
||||
TAG=$(COMPOSE_TAG) DEV_DOCKER_TAG_BASE=$(DEV_DOCKER_TAG_BASE) docker-compose -f tools/docker-compose-cluster.yml -f tools/elastic/docker-compose.logstash-link-cluster.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||
|
||||
prometheus:
|
||||
|
||||
@@ -86,7 +86,14 @@ def oauth2_getattribute(self, attr):
|
||||
# Custom method to override
|
||||
# oauth2_provider.settings.OAuth2ProviderSettings.__getattribute__
|
||||
from django.conf import settings
|
||||
val = settings.OAUTH2_PROVIDER.get(attr)
|
||||
val = None
|
||||
if 'migrate' not in sys.argv:
|
||||
# certain Django OAuth Toolkit migrations actually reference
|
||||
# setting lookups for references to model classes (e.g.,
|
||||
# oauth2_settings.REFRESH_TOKEN_MODEL)
|
||||
# If we're doing an OAuth2 setting lookup *while running* a migration,
|
||||
# don't do our usual "Configure Tower in Tower" database setting lookup
|
||||
val = settings.OAUTH2_PROVIDER.get(attr)
|
||||
if val is None:
|
||||
val = object.__getattribute__(self, attr)
|
||||
return val
|
||||
|
||||
@@ -62,3 +62,14 @@ register(
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
)
|
||||
register(
|
||||
'LOGIN_REDIRECT_OVERRIDE',
|
||||
field_class=fields.CharField,
|
||||
allow_blank=True,
|
||||
required=False,
|
||||
label=_('Login redirect override URL'),
|
||||
help_text=_('URL to which unauthorized users will be redirected to log in. '
|
||||
'If blank, users will be sent to the Tower login page.'),
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
)
|
||||
|
||||
@@ -1472,7 +1472,7 @@ class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ProjectUpdate
|
||||
fields = ('*', 'project', 'job_type', '-controller_node')
|
||||
fields = ('*', 'project', 'job_type', 'job_tags', '-controller_node')
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(ProjectUpdateSerializer, self).get_related(obj)
|
||||
@@ -2456,12 +2456,18 @@ class CredentialTypeSerializer(BaseSerializer):
|
||||
raise PermissionDenied(
|
||||
detail=_("Modifications not allowed for managed credential types")
|
||||
)
|
||||
|
||||
old_inputs = {}
|
||||
if self.instance:
|
||||
old_inputs = copy.deepcopy(self.instance.inputs)
|
||||
|
||||
ret = super(CredentialTypeSerializer, self).validate(attrs)
|
||||
|
||||
if self.instance and self.instance.credentials.exists():
|
||||
if 'inputs' in attrs and attrs['inputs'] != self.instance.inputs:
|
||||
if 'inputs' in attrs and old_inputs != self.instance.inputs:
|
||||
raise PermissionDenied(
|
||||
detail= _("Modifications to inputs are not allowed for credential types that are in use")
|
||||
)
|
||||
ret = super(CredentialTypeSerializer, self).validate(attrs)
|
||||
|
||||
if 'kind' in attrs and attrs['kind'] not in ('cloud', 'net'):
|
||||
raise serializers.ValidationError({
|
||||
@@ -4658,6 +4664,10 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
|
||||
|
||||
def get_summary_fields(self, obj):
|
||||
summary_fields = super(ScheduleSerializer, self).get_summary_fields(obj)
|
||||
|
||||
if isinstance(obj.unified_job_template, SystemJobTemplate):
|
||||
summary_fields['unified_job_template']['job_type'] = obj.unified_job_template.job_type
|
||||
|
||||
if 'inventory' in summary_fields:
|
||||
return summary_fields
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Cancel Inventory Update
|
||||
|
||||
Make a GET request to this resource to determine if the inventory update can be
|
||||
cancelled. The response will include the following field:
|
||||
canceled. The response will include the following field:
|
||||
|
||||
* `can_cancel`: Indicates whether this update can be canceled (boolean,
|
||||
read-only)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% ifmeth GET %}
|
||||
# Determine if a Job can be cancelled
|
||||
# Determine if a Job can be canceled
|
||||
|
||||
Make a GET request to this resource to determine if the job can be cancelled.
|
||||
Make a GET request to this resource to determine if the job can be canceled.
|
||||
The response will include the following field:
|
||||
|
||||
* `can_cancel`: Indicates whether this job can be canceled (boolean, read-only)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Cancel Project Update
|
||||
|
||||
Make a GET request to this resource to determine if the project update can be
|
||||
cancelled. The response will include the following field:
|
||||
canceled. The response will include the following field:
|
||||
|
||||
* `can_cancel`: Indicates whether this update can be canceled (boolean,
|
||||
read-only)
|
||||
|
||||
@@ -72,12 +72,11 @@ from awx.api.generics import (
|
||||
SubListDestroyAPIView
|
||||
)
|
||||
from awx.api.versioning import reverse
|
||||
from awx.conf.license import get_license
|
||||
from awx.main import models
|
||||
from awx.main.utils import (
|
||||
camelcase_to_underscore,
|
||||
extract_ansible_vars,
|
||||
get_awx_version,
|
||||
get_awx_http_client_headers,
|
||||
get_object_or_400,
|
||||
getattrd,
|
||||
get_pk_from_dict,
|
||||
@@ -1386,6 +1385,7 @@ class CredentialExternalTest(SubDetailAPIView):
|
||||
|
||||
model = models.Credential
|
||||
serializer_class = serializers.EmptySerializer
|
||||
obj_permission_type = 'use'
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
@@ -1643,18 +1643,6 @@ class HostInsights(GenericAPIView):
|
||||
|
||||
return session
|
||||
|
||||
def _get_headers(self):
|
||||
license = get_license(show_key=False).get('license_type', 'UNLICENSED')
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': '{} {} ({})'.format(
|
||||
'AWX' if license == 'open' else 'Red Hat Ansible Tower',
|
||||
get_awx_version(),
|
||||
license
|
||||
)
|
||||
}
|
||||
|
||||
return headers
|
||||
|
||||
def _get_platform_info(self, host, session, headers):
|
||||
url = '{}/api/inventory/v1/hosts?insights_id={}'.format(
|
||||
@@ -1721,7 +1709,7 @@ class HostInsights(GenericAPIView):
|
||||
username = cred.get_input('username', default='')
|
||||
password = cred.get_input('password', default='')
|
||||
session = self._get_session(username, password)
|
||||
headers = self._get_headers()
|
||||
headers = get_awx_http_client_headers()
|
||||
|
||||
data = self._get_insights(host, session, headers)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -60,6 +60,7 @@ class ApiRootView(APIView):
|
||||
data['oauth2'] = drf_reverse('api:oauth_authorization_root_view')
|
||||
data['custom_logo'] = settings.CUSTOM_LOGO
|
||||
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
|
||||
data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE
|
||||
return Response(data)
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
# Tower
|
||||
from awx.main.utils.common import get_licenser
|
||||
|
||||
__all__ = ['get_license']
|
||||
|
||||
|
||||
def _get_validated_license_data():
|
||||
from awx.main.utils.common import get_licenser
|
||||
return get_licenser().validate()
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
from awx.conf.migrations import _reencrypt
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -12,5 +11,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(_reencrypt.replace_aesecb_fernet),
|
||||
# This list is intentionally empty.
|
||||
# Tower 3.2 included several data migrations that are no longer
|
||||
# necessary (this list is now empty because Tower 3.2 is past EOL and
|
||||
# cannot be directly upgraded to modern versions of Tower)
|
||||
]
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from django.utils.encoding import smart_str
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher
|
||||
from cryptography.hazmat.primitives.ciphers.algorithms import AES
|
||||
from cryptography.hazmat.primitives.ciphers.modes import ECB
|
||||
|
||||
from awx.conf import settings_registry
|
||||
|
||||
|
||||
__all__ = ['replace_aesecb_fernet', 'get_encryption_key', 'encrypt_field',
|
||||
'decrypt_value', 'decrypt_value', 'should_decrypt_field']
|
||||
|
||||
|
||||
def replace_aesecb_fernet(apps, schema_editor):
|
||||
from awx.main.utils.encryption import encrypt_field
|
||||
Setting = apps.get_model('conf', 'Setting')
|
||||
|
||||
for setting in Setting.objects.filter().order_by('pk'):
|
||||
if settings_registry.is_setting_encrypted(setting.key):
|
||||
if should_decrypt_field(setting.value):
|
||||
setting.value = decrypt_field(setting, 'value')
|
||||
setting.value = encrypt_field(setting, 'value')
|
||||
setting.save()
|
||||
__all__ = ['get_encryption_key', 'decrypt_field']
|
||||
|
||||
|
||||
def get_encryption_key(field_name, pk=None):
|
||||
@@ -76,38 +59,3 @@ def decrypt_field(instance, field_name, subfield=None):
|
||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||
|
||||
return decrypt_value(key, value)
|
||||
|
||||
|
||||
def encrypt_field(instance, field_name, ask=False, subfield=None, skip_utf8=False):
|
||||
'''
|
||||
Return content of the given instance and field name encrypted.
|
||||
'''
|
||||
value = getattr(instance, field_name)
|
||||
if isinstance(value, dict) and subfield is not None:
|
||||
value = value[subfield]
|
||||
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
|
||||
return value
|
||||
if skip_utf8:
|
||||
utf8 = False
|
||||
else:
|
||||
utf8 = type(value) == str
|
||||
value = smart_str(value)
|
||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||
encryptor = Cipher(AES(key), ECB(), default_backend()).encryptor()
|
||||
block_size = 16
|
||||
while len(value) % block_size != 0:
|
||||
value += '\x00'
|
||||
encrypted = encryptor.update(value) + encryptor.finalize()
|
||||
b64data = base64.b64encode(encrypted)
|
||||
tokens = ['$encrypted', 'AES', b64data]
|
||||
if utf8:
|
||||
# If the value to encrypt is utf-8, we need to add a marker so we
|
||||
# know to decode the data when it's decrypted later
|
||||
tokens.insert(1, 'UTF8')
|
||||
return '$'.join(tokens)
|
||||
|
||||
|
||||
def should_decrypt_field(value):
|
||||
if hasattr(value, 'startswith'):
|
||||
return value.startswith('$encrypted$') and '$AESCBC$' not in value
|
||||
return False
|
||||
|
||||
@@ -166,6 +166,8 @@ def instance_info(since, include_hostnames=False):
|
||||
instances = models.Instance.objects.values_list('hostname').values(
|
||||
'uuid', 'version', 'capacity', 'cpu', 'memory', 'managed_by_policy', 'hostname', 'last_isolated_check', 'enabled')
|
||||
for instance in instances:
|
||||
consumed_capacity = sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'],
|
||||
status__in=('running', 'waiting')))
|
||||
instance_info = {
|
||||
'uuid': instance['uuid'],
|
||||
'version': instance['version'],
|
||||
@@ -174,7 +176,9 @@ def instance_info(since, include_hostnames=False):
|
||||
'memory': instance['memory'],
|
||||
'managed_by_policy': instance['managed_by_policy'],
|
||||
'last_isolated_check': _get_isolated_datetime(instance['last_isolated_check']),
|
||||
'enabled': instance['enabled']
|
||||
'enabled': instance['enabled'],
|
||||
'consumed_capacity': consumed_capacity,
|
||||
'remaining_capacity': instance['capacity'] - consumed_capacity
|
||||
}
|
||||
if include_hostnames is True:
|
||||
instance_info['hostname'] = instance['hostname']
|
||||
|
||||
@@ -15,6 +15,7 @@ from awx.conf.license import get_license
|
||||
from awx.main.models import Job
|
||||
from awx.main.access import access_registry
|
||||
from awx.main.models.ha import TowerAnalyticsState
|
||||
from awx.main.utils import get_awx_http_client_headers
|
||||
|
||||
|
||||
__all__ = ['register', 'gather', 'ship', 'table_version']
|
||||
@@ -165,11 +166,15 @@ def ship(path):
|
||||
return logger.error('REDHAT_PASSWORD is not set')
|
||||
with open(path, 'rb') as f:
|
||||
files = {'file': (os.path.basename(path), f, settings.INSIGHTS_AGENT_MIME)}
|
||||
response = requests.post(url,
|
||||
files=files,
|
||||
verify="/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
|
||||
auth=(rh_user, rh_password),
|
||||
timeout=(31, 31))
|
||||
s = requests.Session()
|
||||
s.headers = get_awx_http_client_headers()
|
||||
s.headers.pop('Content-Type')
|
||||
response = s.post(url,
|
||||
files=files,
|
||||
verify="/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
|
||||
auth=(rh_user, rh_password),
|
||||
headers=s.headers,
|
||||
timeout=(31, 31))
|
||||
if response.status_code != 202:
|
||||
return logger.exception('Upload failed with status {}, {}'.format(response.status_code,
|
||||
response.text))
|
||||
|
||||
@@ -46,6 +46,8 @@ INSTANCE_MEMORY = Gauge('awx_instance_memory', 'RAM (Kb) on each node in a Tower
|
||||
INSTANCE_INFO = Info('awx_instance', 'Info about each node in a Tower system', ['hostname', 'instance_uuid',])
|
||||
INSTANCE_LAUNCH_TYPE = Gauge('awx_instance_launch_type_total', 'Type of Job launched', ['node', 'launch_type',])
|
||||
INSTANCE_STATUS = Gauge('awx_instance_status_total', 'Status of Job launched', ['node', 'status',])
|
||||
INSTANCE_CONSUMED_CAPACITY = Gauge('awx_instance_consumed_capacity', 'Consumed capacity of each node in a Tower system', ['hostname', 'instance_uuid',])
|
||||
INSTANCE_REMAINING_CAPACITY = Gauge('awx_instance_remaining_capacity', 'Remaining capacity of each node in a Tower system', ['hostname', 'instance_uuid',])
|
||||
|
||||
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license')
|
||||
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license')
|
||||
@@ -104,6 +106,8 @@ def metrics():
|
||||
INSTANCE_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['capacity'])
|
||||
INSTANCE_CPU.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['cpu'])
|
||||
INSTANCE_MEMORY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['memory'])
|
||||
INSTANCE_CONSUMED_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['consumed_capacity'])
|
||||
INSTANCE_REMAINING_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['remaining_capacity'])
|
||||
INSTANCE_INFO.labels(hostname=hostname, instance_uuid=uuid).info({
|
||||
'enabled': str(instance_data[uuid]['enabled']),
|
||||
'last_isolated_check': getattr(instance_data[uuid], 'last_isolated_check', 'None'),
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import pre_migrate
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
def raise_migration_flag(**kwargs):
|
||||
from awx.main.tasks import set_migration_flag
|
||||
set_migration_flag.delay()
|
||||
|
||||
|
||||
class MainConfig(AppConfig):
|
||||
|
||||
name = 'awx.main'
|
||||
verbose_name = _('Main')
|
||||
|
||||
def ready(self):
|
||||
pre_migrate.connect(raise_migration_flag, sender=self)
|
||||
|
||||
@@ -514,6 +514,17 @@ register(
|
||||
category_slug='jobs'
|
||||
)
|
||||
|
||||
register(
|
||||
'GALAXY_IGNORE_CERTS',
|
||||
field_class=fields.BooleanField,
|
||||
default=False,
|
||||
label=_('Ignore Ansible Galaxy SSL Certificate Verification'),
|
||||
help_text=_('If set to true, certificate validation will not be done when'
|
||||
'installing content from any Galaxy server.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs'
|
||||
)
|
||||
|
||||
register(
|
||||
'STDOUT_MAX_BYTES_DISPLAY',
|
||||
field_class=fields.IntegerField,
|
||||
|
||||
@@ -123,8 +123,16 @@ class PoolWorker(object):
|
||||
# if any tasks were finished, removed them from the managed tasks for
|
||||
# this worker
|
||||
for uuid in finished:
|
||||
self.messages_finished += 1
|
||||
del self.managed_tasks[uuid]
|
||||
try:
|
||||
del self.managed_tasks[uuid]
|
||||
self.messages_finished += 1
|
||||
except KeyError:
|
||||
# ansible _sometimes_ appears to send events w/ duplicate UUIDs;
|
||||
# UUIDs for ansible events are *not* actually globally unique
|
||||
# when this occurs, it's _fine_ to ignore this KeyError because
|
||||
# the purpose of self.managed_tasks is to just track internal
|
||||
# state of which events are *currently* being processed.
|
||||
pass
|
||||
|
||||
@property
|
||||
def current_task(self):
|
||||
|
||||
@@ -32,15 +32,14 @@ def set_pythonpath(venv_libdir, env):
|
||||
|
||||
class IsolatedManager(object):
|
||||
|
||||
def __init__(self, cancelled_callback=None, check_callback=None, pod_manager=None):
|
||||
def __init__(self, canceled_callback=None, check_callback=None, pod_manager=None):
|
||||
"""
|
||||
:param cancelled_callback: a callable - which returns `True` or `False`
|
||||
:param canceled_callback: a callable - which returns `True` or `False`
|
||||
- signifying if the job has been prematurely
|
||||
cancelled
|
||||
canceled
|
||||
"""
|
||||
self.cancelled_callback = cancelled_callback
|
||||
self.canceled_callback = canceled_callback
|
||||
self.check_callback = check_callback
|
||||
self.idle_timeout = max(60, 2 * settings.AWX_ISOLATED_CONNECTION_TIMEOUT)
|
||||
self.started_at = None
|
||||
self.captured_command_artifact = False
|
||||
self.instance = None
|
||||
@@ -106,9 +105,8 @@ class IsolatedManager(object):
|
||||
'envvars': env,
|
||||
'finished_callback': finished_callback,
|
||||
'verbosity': verbosity,
|
||||
'cancel_callback': self.cancelled_callback,
|
||||
'cancel_callback': self.canceled_callback,
|
||||
'settings': {
|
||||
'idle_timeout': self.idle_timeout,
|
||||
'job_timeout': settings.AWX_ISOLATED_LAUNCH_TIMEOUT,
|
||||
'pexpect_timeout': getattr(settings, 'PEXPECT_TIMEOUT', 5),
|
||||
'suppress_ansible_output': True,
|
||||
@@ -118,7 +116,7 @@ class IsolatedManager(object):
|
||||
def path_to(self, *args):
|
||||
return os.path.join(self.private_data_dir, *args)
|
||||
|
||||
def run_management_playbook(self, playbook, private_data_dir, **kw):
|
||||
def run_management_playbook(self, playbook, private_data_dir, idle_timeout=None, **kw):
|
||||
iso_dir = tempfile.mkdtemp(
|
||||
prefix=playbook,
|
||||
dir=private_data_dir
|
||||
@@ -126,6 +124,10 @@ class IsolatedManager(object):
|
||||
params = self.runner_params.copy()
|
||||
params['playbook'] = playbook
|
||||
params['private_data_dir'] = iso_dir
|
||||
if idle_timeout:
|
||||
params['settings']['idle_timeout'] = idle_timeout
|
||||
else:
|
||||
params['settings'].pop('idle_timeout', None)
|
||||
params.update(**kw)
|
||||
if all([
|
||||
getattr(settings, 'AWX_ISOLATED_KEY_GENERATION', False) is True,
|
||||
@@ -177,6 +179,7 @@ class IsolatedManager(object):
|
||||
logger.debug('Starting job {} on isolated host with `run_isolated.yml` playbook.'.format(self.instance.id))
|
||||
runner_obj = self.run_management_playbook('run_isolated.yml',
|
||||
self.private_data_dir,
|
||||
idle_timeout=max(60, 2 * settings.AWX_ISOLATED_CONNECTION_TIMEOUT),
|
||||
extravars=extravars)
|
||||
|
||||
if runner_obj.status == 'failed':
|
||||
@@ -208,14 +211,14 @@ class IsolatedManager(object):
|
||||
dispatcher = CallbackQueueDispatcher()
|
||||
|
||||
while status == 'failed':
|
||||
canceled = self.cancelled_callback() if self.cancelled_callback else False
|
||||
canceled = self.canceled_callback() if self.canceled_callback else False
|
||||
if not canceled and time.time() - last_check < interval:
|
||||
# If the job isn't cancelled, but we haven't waited `interval` seconds, wait longer
|
||||
# If the job isn't canceled, but we haven't waited `interval` seconds, wait longer
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
if canceled:
|
||||
logger.warning('Isolated job {} was manually cancelled.'.format(self.instance.id))
|
||||
logger.warning('Isolated job {} was manually canceled.'.format(self.instance.id))
|
||||
|
||||
logger.debug('Checking on isolated job {} with `check_isolated.yml`.'.format(self.instance.id))
|
||||
runner_obj = self.run_management_playbook('check_isolated.yml',
|
||||
|
||||
@@ -28,6 +28,7 @@ from awx.main.models.inventory import (
|
||||
Host
|
||||
)
|
||||
from awx.main.utils.mem_inventory import MemInventory, dict_to_mem_data
|
||||
from awx.main.utils.safe_yaml import sanitize_jinja
|
||||
|
||||
# other AWX imports
|
||||
from awx.main.models.rbac import batch_role_ancestor_rebuilding
|
||||
@@ -795,6 +796,10 @@ class Command(BaseCommand):
|
||||
if self.instance_id_var:
|
||||
instance_id = self._get_instance_id(mem_host.variables)
|
||||
host_attrs['instance_id'] = instance_id
|
||||
try:
|
||||
sanitize_jinja(mem_host_name)
|
||||
except ValueError as e:
|
||||
raise ValueError(str(e) + ': {}'.format(mem_host_name))
|
||||
db_host = self.inventory.hosts.update_or_create(name=mem_host_name, defaults=host_attrs)[0]
|
||||
if enabled is False:
|
||||
logger.debug('Host "%s" added (disabled)', mem_host_name)
|
||||
|
||||
129
awx/main/management/commands/regenerate_secret_key.py
Normal file
129
awx/main/management/commands/regenerate_secret_key.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
from awx.conf import settings_registry
|
||||
from awx.conf.models import Setting
|
||||
from awx.conf.signals import on_post_save_setting
|
||||
from awx.main.models import (
|
||||
UnifiedJob, Credential, NotificationTemplate, Job, JobTemplate, WorkflowJob,
|
||||
WorkflowJobTemplate, OAuth2Application
|
||||
)
|
||||
from awx.main.utils.encryption import (
|
||||
encrypt_field, decrypt_field, encrypt_value, decrypt_value, get_encryption_key
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Regenerate a new SECRET_KEY value and re-encrypt every secret in the
|
||||
Tower database.
|
||||
"""
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, **options):
|
||||
self.old_key = settings.SECRET_KEY
|
||||
self.new_key = base64.encodebytes(os.urandom(33)).decode().rstrip()
|
||||
self._notification_templates()
|
||||
self._credentials()
|
||||
self._unified_jobs()
|
||||
self._oauth2_app_secrets()
|
||||
self._settings()
|
||||
self._survey_passwords()
|
||||
return self.new_key
|
||||
|
||||
def _notification_templates(self):
|
||||
for nt in NotificationTemplate.objects.iterator():
|
||||
CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NotificationTemplate.NOTIFICATION_TYPES])
|
||||
notification_class = CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type]
|
||||
for field in filter(lambda x: notification_class.init_parameters[x]['type'] == "password",
|
||||
notification_class.init_parameters):
|
||||
nt.notification_configuration[field] = decrypt_field(nt, 'notification_configuration', subfield=field, secret_key=self.old_key)
|
||||
nt.notification_configuration[field] = encrypt_field(nt, 'notification_configuration', subfield=field, secret_key=self.new_key)
|
||||
nt.save()
|
||||
|
||||
def _credentials(self):
|
||||
for credential in Credential.objects.iterator():
|
||||
for field_name in credential.credential_type.secret_fields:
|
||||
if field_name in credential.inputs:
|
||||
credential.inputs[field_name] = decrypt_field(
|
||||
credential,
|
||||
field_name,
|
||||
secret_key=self.old_key
|
||||
)
|
||||
credential.inputs[field_name] = encrypt_field(
|
||||
credential,
|
||||
field_name,
|
||||
secret_key=self.new_key
|
||||
)
|
||||
credential.save()
|
||||
|
||||
def _unified_jobs(self):
|
||||
for uj in UnifiedJob.objects.iterator():
|
||||
if uj.start_args:
|
||||
uj.start_args = decrypt_field(
|
||||
uj,
|
||||
'start_args',
|
||||
secret_key=self.old_key
|
||||
)
|
||||
uj.start_args = encrypt_field(uj, 'start_args', secret_key=self.new_key)
|
||||
uj.save()
|
||||
|
||||
def _oauth2_app_secrets(self):
|
||||
for app in OAuth2Application.objects.iterator():
|
||||
raw = app.client_secret
|
||||
app.client_secret = raw
|
||||
encrypted = encrypt_value(raw, secret_key=self.new_key)
|
||||
OAuth2Application.objects.filter(pk=app.pk).update(client_secret=encrypted)
|
||||
|
||||
def _settings(self):
|
||||
# don't update memcached, the *actual* value isn't changing
|
||||
post_save.disconnect(on_post_save_setting, sender=Setting)
|
||||
for setting in Setting.objects.filter().order_by('pk'):
|
||||
if settings_registry.is_setting_encrypted(setting.key):
|
||||
setting.value = decrypt_field(setting, 'value', secret_key=self.old_key)
|
||||
setting.value = encrypt_field(setting, 'value', secret_key=self.new_key)
|
||||
setting.save()
|
||||
|
||||
def _survey_passwords(self):
|
||||
for _type in (JobTemplate, WorkflowJobTemplate):
|
||||
for jt in _type.objects.exclude(survey_spec={}):
|
||||
changed = False
|
||||
if jt.survey_spec.get('spec', []):
|
||||
for field in jt.survey_spec['spec']:
|
||||
if field.get('type') == 'password' and field.get('default', ''):
|
||||
raw = decrypt_value(
|
||||
get_encryption_key('value', None, secret_key=self.old_key),
|
||||
field['default']
|
||||
)
|
||||
field['default'] = encrypt_value(
|
||||
raw,
|
||||
pk=None,
|
||||
secret_key=self.new_key
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
jt.save(update_fields=["survey_spec"])
|
||||
|
||||
for _type in (Job, WorkflowJob):
|
||||
for job in _type.objects.exclude(survey_passwords={}).iterator():
|
||||
changed = False
|
||||
for key in job.survey_passwords:
|
||||
if key in job.extra_vars:
|
||||
extra_vars = json.loads(job.extra_vars)
|
||||
if not extra_vars.get(key):
|
||||
continue
|
||||
raw = decrypt_value(
|
||||
get_encryption_key('value', None, secret_key=self.old_key),
|
||||
extra_vars[key]
|
||||
)
|
||||
extra_vars[key] = encrypt_value(raw, pk=None, secret_key=self.new_key)
|
||||
job.extra_vars = json.dumps(extra_vars)
|
||||
changed = True
|
||||
if changed:
|
||||
job.save(update_fields=["extra_vars"])
|
||||
@@ -13,8 +13,7 @@ import urllib.parse
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.db import IntegrityError, connection
|
||||
from django.db import IntegrityError
|
||||
from django.utils.functional import curry
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.apps import apps
|
||||
@@ -24,6 +23,7 @@ from django.urls import reverse, resolve
|
||||
|
||||
from awx.main.models import ActivityStream
|
||||
from awx.main.utils.named_url_graph import generate_graph, GraphNode
|
||||
from awx.main.utils.db import migration_in_progress_check_or_relase
|
||||
from awx.conf import fields, register
|
||||
|
||||
|
||||
@@ -213,8 +213,7 @@ class URLModificationMiddleware(MiddlewareMixin):
|
||||
class MigrationRanCheckMiddleware(MiddlewareMixin):
|
||||
|
||||
def process_request(self, request):
|
||||
executor = MigrationExecutor(connection)
|
||||
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
|
||||
if bool(plan) and \
|
||||
getattr(resolve(request.path), 'url_name', '') != 'migrations_notran':
|
||||
if migration_in_progress_check_or_relase():
|
||||
if getattr(resolve(request.path), 'url_name', '') == 'migrations_notran':
|
||||
return
|
||||
return redirect(reverse("ui:migrations_notran"))
|
||||
|
||||
@@ -7,12 +7,6 @@ from django.db import migrations, models
|
||||
|
||||
# AWX
|
||||
from awx.main.migrations import ActivityStreamDisabledMigration
|
||||
from awx.main.migrations import _inventory_source as invsrc
|
||||
from awx.main.migrations import _migration_utils as migration_utils
|
||||
from awx.main.migrations import _reencrypt as reencrypt
|
||||
from awx.main.migrations import _scan_jobs as scan_jobs
|
||||
from awx.main.migrations import _credentialtypes as credentialtypes
|
||||
from awx.main.migrations import _azure_credentials as azurecreds
|
||||
import awx.main.fields
|
||||
|
||||
|
||||
@@ -23,16 +17,8 @@ class Migration(ActivityStreamDisabledMigration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Inventory Refresh
|
||||
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
|
||||
migrations.RunPython(invsrc.remove_rax_inventory_sources),
|
||||
migrations.RunPython(azurecreds.remove_azure_credentials),
|
||||
migrations.RunPython(invsrc.remove_azure_inventory_sources),
|
||||
migrations.RunPython(invsrc.remove_inventory_source_with_no_inventory_link),
|
||||
migrations.RunPython(invsrc.rename_inventory_sources),
|
||||
migrations.RunPython(reencrypt.replace_aesecb_fernet),
|
||||
migrations.RunPython(scan_jobs.migrate_scan_job_templates),
|
||||
|
||||
migrations.RunPython(credentialtypes.migrate_to_v2_credentials),
|
||||
migrations.RunPython(credentialtypes.migrate_job_credentials),
|
||||
# This list is intentionally empty.
|
||||
# Tower 3.2 included several data migrations that are no longer
|
||||
# necessary (this list is now empty because Tower 3.2 is past EOL and
|
||||
# cannot be directly upgraded to modern versions of Tower)
|
||||
]
|
||||
|
||||
@@ -15,8 +15,6 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
|
||||
migrations.RunPython(credentialtypes.create_rhv_tower_credtype),
|
||||
migrations.AlterField(
|
||||
model_name='inventorysource',
|
||||
name='source',
|
||||
|
||||
@@ -3,8 +3,6 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
from awx.main.migrations import ActivityStreamDisabledMigration
|
||||
from awx.main.migrations import _reencrypt as reencrypt
|
||||
from awx.main.migrations import _migration_utils as migration_utils
|
||||
|
||||
|
||||
class Migration(ActivityStreamDisabledMigration):
|
||||
@@ -14,6 +12,8 @@ class Migration(ActivityStreamDisabledMigration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
|
||||
migrations.RunPython(reencrypt.encrypt_survey_passwords),
|
||||
# This list is intentionally empty.
|
||||
# Tower 3.2 included several data migrations that are no longer
|
||||
# necessary (this list is now empty because Tower 3.2 is past EOL and
|
||||
# cannot be directly upgraded to modern versions of Tower)
|
||||
]
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
# AWX
|
||||
from awx.main.migrations import _migration_utils as migration_utils
|
||||
from awx.main.migrations import _credentialtypes as credentialtypes
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
@@ -15,6 +11,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
|
||||
migrations.RunPython(credentialtypes.add_azure_cloud_environment_field),
|
||||
# This list is intentionally empty.
|
||||
# Tower 3.2 included several data migrations that are no longer
|
||||
# necessary (this list is now empty because Tower 3.2 is past EOL and
|
||||
# cannot be directly upgraded to modern versions of Tower)
|
||||
]
|
||||
|
||||
@@ -19,11 +19,11 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='systemjob',
|
||||
name='job_type',
|
||||
field=models.CharField(blank=True, choices=[('cleanup_jobs', 'Remove jobs older than a certain number of days'), ('cleanup_activitystream', 'Remove activity stream entries older than a certain number of days'), ('clearsessions', 'Removes expired browser sessions from the database'), ('cleartokens', 'Removes expired OAuth 2 access tokens and refresh tokens')], default='', max_length=32),
|
||||
field=models.CharField(blank=True, choices=[('cleanup_jobs', 'Remove jobs older than a certain number of days'), ('cleanup_activitystream', 'Remove activity stream entries older than a certain number of days'), ('cleanup_sessions', 'Removes expired browser sessions from the database'), ('cleanup_tokens', 'Removes expired OAuth 2 access tokens and refresh tokens')], default='', max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemjobtemplate',
|
||||
name='job_type',
|
||||
field=models.CharField(blank=True, choices=[('cleanup_jobs', 'Remove jobs older than a certain number of days'), ('cleanup_activitystream', 'Remove activity stream entries older than a certain number of days'), ('clearsessions', 'Removes expired browser sessions from the database'), ('cleartokens', 'Removes expired OAuth 2 access tokens and refresh tokens')], default='', max_length=32),
|
||||
field=models.CharField(blank=True, choices=[('cleanup_jobs', 'Remove jobs older than a certain number of days'), ('cleanup_activitystream', 'Remove activity stream entries older than a certain number of days'), ('cleanup_sessions', 'Removes expired browser sessions from the database'), ('cleanup_tokens', 'Removes expired OAuth 2 access tokens and refresh tokens')], default='', max_length=32),
|
||||
),
|
||||
]
|
||||
|
||||
21
awx/main/migrations/0099_v361_license_cleanup.py
Normal file
21
awx/main/migrations/0099_v361_license_cleanup.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def _cleanup_license_setting(apps, schema_editor):
|
||||
Setting = apps.get_model('conf', 'Setting')
|
||||
for license in Setting.objects.filter(key='LICENSE').all():
|
||||
for k in ('rh_username', 'rh_password'):
|
||||
license.value.pop(k, None)
|
||||
license.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0098_v360_rename_cyberark_aim_credential_type'),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(_cleanup_license_setting)]
|
||||
18
awx/main/migrations/0100_v370_projectupdate_job_tags.py
Normal file
18
awx/main/migrations/0100_v370_projectupdate_job_tags.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.4 on 2019-11-01 18:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0099_v361_license_cleanup'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='projectupdate',
|
||||
name='job_tags',
|
||||
field=models.CharField(blank=True, default='', help_text='Parts of the project update playbook that will be run.', max_length=1024),
|
||||
),
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
import logging
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
logger = logging.getLogger('awx.main.migrations')
|
||||
|
||||
|
||||
def remove_azure_credentials(apps, schema_editor):
|
||||
'''Azure is not supported as of 3.2 and greater. Instead, azure_rm is
|
||||
supported.
|
||||
'''
|
||||
Credential = apps.get_model('main', 'Credential')
|
||||
logger.debug("Removing all Azure Credentials from database.")
|
||||
Credential.objects.filter(kind='azure').delete()
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from awx.main import utils
|
||||
from awx.main.models import CredentialType
|
||||
from awx.main.utils.encryption import encrypt_field, decrypt_field
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@@ -61,16 +59,6 @@ def _disassociate_non_insights_projects(apps, cred):
|
||||
apps.get_model('main', 'Project').objects.filter(~Q(scm_type='insights') & Q(credential=cred)).update(credential=None)
|
||||
|
||||
|
||||
def migrate_to_v2_credentials(apps, schema_editor):
|
||||
# TODO: remove once legacy/EOL'd Towers no longer support this upgrade path
|
||||
pass
|
||||
|
||||
|
||||
def migrate_job_credentials(apps, schema_editor):
|
||||
# TODO: remove once legacy/EOL'd Towers no longer support this upgrade path
|
||||
pass
|
||||
|
||||
|
||||
def add_vault_id_field(apps, schema_editor):
|
||||
# this is no longer necessary; schemas are defined in code
|
||||
pass
|
||||
@@ -81,21 +69,11 @@ def remove_vault_id_field(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
def create_rhv_tower_credtype(apps, schema_editor):
|
||||
# this is no longer necessary; schemas are defined in code
|
||||
pass
|
||||
|
||||
|
||||
def add_tower_verify_field(apps, schema_editor):
|
||||
# this is no longer necessary; schemas are defined in code
|
||||
pass
|
||||
|
||||
|
||||
def add_azure_cloud_environment_field(apps, schema_editor):
|
||||
# this is no longer necessary; schemas are defined in code
|
||||
pass
|
||||
|
||||
|
||||
def remove_become_methods(apps, schema_editor):
|
||||
# this is no longer necessary; schemas are defined in code
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from awx.main.utils.common import parse_yaml_or_json
|
||||
@@ -8,64 +7,6 @@ from awx.main.utils.common import parse_yaml_or_json
|
||||
logger = logging.getLogger('awx.main.migrations')
|
||||
|
||||
|
||||
def remove_manual_inventory_sources(apps, schema_editor):
|
||||
'''Previously we would automatically create inventory sources after
|
||||
Group creation and we would use the parent Group as our interface for the user.
|
||||
During that process we would create InventorySource that had a source of "manual".
|
||||
'''
|
||||
# TODO: use this in the 3.3 data migrations
|
||||
InventorySource = apps.get_model('main', 'InventorySource')
|
||||
# see models/inventory.py SOURCE_CHOICES - ('', _('Manual'))
|
||||
logger.debug("Removing all Manual InventorySource from database.")
|
||||
InventorySource.objects.filter(source='').delete()
|
||||
|
||||
|
||||
def remove_rax_inventory_sources(apps, schema_editor):
|
||||
'''Rackspace inventory sources are not supported since 3.2, remove them.
|
||||
'''
|
||||
InventorySource = apps.get_model('main', 'InventorySource')
|
||||
logger.debug("Removing all Rackspace InventorySource from database.")
|
||||
InventorySource.objects.filter(source='rax').delete()
|
||||
|
||||
|
||||
def rename_inventory_sources(apps, schema_editor):
|
||||
'''Rename existing InventorySource entries using the following format.
|
||||
{{ inventory_source.name }} - {{ inventory.module }} - {{ number }}
|
||||
The number will be incremented for each InventorySource for the organization.
|
||||
'''
|
||||
Organization = apps.get_model('main', 'Organization')
|
||||
InventorySource = apps.get_model('main', 'InventorySource')
|
||||
|
||||
for org in Organization.objects.iterator():
|
||||
for i, invsrc in enumerate(InventorySource.objects.filter(Q(inventory__organization=org) |
|
||||
Q(deprecated_group__inventory__organization=org)).distinct().all()):
|
||||
|
||||
inventory = invsrc.deprecated_group.inventory if invsrc.deprecated_group else invsrc.inventory
|
||||
name = '{0} - {1} - {2}'.format(invsrc.name, inventory.name, i)
|
||||
logger.debug("Renaming InventorySource({0}) {1} -> {2}".format(
|
||||
invsrc.pk, invsrc.name, name
|
||||
))
|
||||
invsrc.name = name
|
||||
invsrc.save()
|
||||
|
||||
|
||||
def remove_inventory_source_with_no_inventory_link(apps, schema_editor):
|
||||
'''If we cannot determine the Inventory for which an InventorySource exists
|
||||
we can safely remove it.
|
||||
'''
|
||||
InventorySource = apps.get_model('main', 'InventorySource')
|
||||
logger.debug("Removing all InventorySource that have no link to an Inventory from database.")
|
||||
InventorySource.objects.filter(Q(inventory__organization=None) & Q(deprecated_group__inventory=None)).delete()
|
||||
|
||||
|
||||
def remove_azure_inventory_sources(apps, schema_editor):
|
||||
'''Azure inventory sources are not supported since 3.2, remove them.
|
||||
'''
|
||||
InventorySource = apps.get_model('main', 'InventorySource')
|
||||
logger.debug("Removing all Azure InventorySource from database.")
|
||||
InventorySource.objects.filter(source='azure').delete()
|
||||
|
||||
|
||||
def _get_instance_id(from_dict, new_id, default=''):
|
||||
'''logic mostly duplicated with inventory_import command Command._get_instance_id
|
||||
frozen in time here, for purposes of migrations
|
||||
|
||||
@@ -1,79 +1,12 @@
|
||||
import logging
|
||||
import json
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from awx.conf.migrations._reencrypt import (
|
||||
decrypt_field,
|
||||
should_decrypt_field,
|
||||
)
|
||||
from awx.main.utils.encryption import encrypt_field
|
||||
|
||||
from awx.main.notifications.email_backend import CustomEmailBackend
|
||||
from awx.main.notifications.slack_backend import SlackBackend
|
||||
from awx.main.notifications.twilio_backend import TwilioBackend
|
||||
from awx.main.notifications.pagerduty_backend import PagerDutyBackend
|
||||
from awx.main.notifications.hipchat_backend import HipChatBackend
|
||||
from awx.main.notifications.mattermost_backend import MattermostBackend
|
||||
from awx.main.notifications.webhook_backend import WebhookBackend
|
||||
from awx.main.notifications.irc_backend import IrcBackend
|
||||
|
||||
logger = logging.getLogger('awx.main.migrations')
|
||||
|
||||
__all__ = ['replace_aesecb_fernet']
|
||||
|
||||
|
||||
NOTIFICATION_TYPES = [('email', _('Email'), CustomEmailBackend),
|
||||
('slack', _('Slack'), SlackBackend),
|
||||
('twilio', _('Twilio'), TwilioBackend),
|
||||
('pagerduty', _('Pagerduty'), PagerDutyBackend),
|
||||
('hipchat', _('HipChat'), HipChatBackend),
|
||||
('mattermost', _('Mattermost'), MattermostBackend),
|
||||
('webhook', _('Webhook'), WebhookBackend),
|
||||
('irc', _('IRC'), IrcBackend)]
|
||||
|
||||
|
||||
PASSWORD_FIELDS = ('password', 'security_token', 'ssh_key_data', 'ssh_key_unlock',
|
||||
'become_password', 'vault_password', 'secret', 'authorize_password')
|
||||
|
||||
|
||||
def replace_aesecb_fernet(apps, schema_editor):
|
||||
_notification_templates(apps)
|
||||
_credentials(apps)
|
||||
_unified_jobs(apps)
|
||||
|
||||
|
||||
def _notification_templates(apps):
|
||||
NotificationTemplate = apps.get_model('main', 'NotificationTemplate')
|
||||
for nt in NotificationTemplate.objects.all():
|
||||
CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NOTIFICATION_TYPES])
|
||||
notification_class = CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type]
|
||||
for field in filter(lambda x: notification_class.init_parameters[x]['type'] == "password",
|
||||
notification_class.init_parameters):
|
||||
if should_decrypt_field(nt.notification_configuration[field]):
|
||||
nt.notification_configuration[field] = decrypt_field(nt, 'notification_configuration', subfield=field)
|
||||
nt.notification_configuration[field] = encrypt_field(nt, 'notification_configuration', subfield=field)
|
||||
nt.save()
|
||||
|
||||
|
||||
def _credentials(apps):
|
||||
for credential in apps.get_model('main', 'Credential').objects.all():
|
||||
for field_name in PASSWORD_FIELDS:
|
||||
value = getattr(credential, field_name)
|
||||
if should_decrypt_field(value):
|
||||
value = decrypt_field(credential, field_name)
|
||||
setattr(credential, field_name, value)
|
||||
setattr(credential, field_name, encrypt_field(credential, field_name))
|
||||
credential.save()
|
||||
|
||||
|
||||
def _unified_jobs(apps):
|
||||
UnifiedJob = apps.get_model('main', 'UnifiedJob')
|
||||
for uj in UnifiedJob.objects.all():
|
||||
if uj.start_args is not None:
|
||||
if should_decrypt_field(uj.start_args):
|
||||
uj.start_args = decrypt_field(uj, 'start_args')
|
||||
uj.start_args = encrypt_field(uj, 'start_args')
|
||||
uj.save()
|
||||
__all__ = []
|
||||
|
||||
|
||||
def blank_old_start_args(apps, schema_editor):
|
||||
@@ -91,53 +24,3 @@ def blank_old_start_args(apps, schema_editor):
|
||||
logger.debug('Blanking job args for %s', uj.pk)
|
||||
uj.start_args = ''
|
||||
uj.save()
|
||||
|
||||
|
||||
def encrypt_survey_passwords(apps, schema_editor):
|
||||
_encrypt_survey_passwords(
|
||||
apps.get_model('main', 'Job'),
|
||||
apps.get_model('main', 'JobTemplate'),
|
||||
apps.get_model('main', 'WorkflowJob'),
|
||||
apps.get_model('main', 'WorkflowJobTemplate'),
|
||||
)
|
||||
|
||||
|
||||
def _encrypt_survey_passwords(Job, JobTemplate, WorkflowJob, WorkflowJobTemplate):
|
||||
from awx.main.utils.encryption import encrypt_value
|
||||
for _type in (JobTemplate, WorkflowJobTemplate):
|
||||
for jt in _type.objects.exclude(survey_spec={}):
|
||||
changed = False
|
||||
if jt.survey_spec.get('spec', []):
|
||||
for field in jt.survey_spec['spec']:
|
||||
if field.get('type') == 'password' and field.get('default', ''):
|
||||
default = field['default']
|
||||
if default.startswith('$encrypted$'):
|
||||
if default == '$encrypted$':
|
||||
# If you have a survey_spec with a literal
|
||||
# '$encrypted$' as the default, you have
|
||||
# encountered a known bug in awx/Tower
|
||||
# https://github.com/ansible/ansible-tower/issues/7800
|
||||
logger.error(
|
||||
'{}.pk={} survey_spec has ambiguous $encrypted$ default for {}, needs attention...'.format(jt, jt.pk, field['variable'])
|
||||
)
|
||||
field['default'] = ''
|
||||
changed = True
|
||||
continue
|
||||
field['default'] = encrypt_value(field['default'], pk=None)
|
||||
changed = True
|
||||
if changed:
|
||||
jt.save()
|
||||
|
||||
for _type in (Job, WorkflowJob):
|
||||
for job in _type.objects.defer('result_stdout_text').exclude(survey_passwords={}).iterator():
|
||||
changed = False
|
||||
for key in job.survey_passwords:
|
||||
if key in job.extra_vars:
|
||||
extra_vars = json.loads(job.extra_vars)
|
||||
if not extra_vars.get(key, '') or extra_vars[key].startswith('$encrypted$'):
|
||||
continue
|
||||
extra_vars[key] = encrypt_value(extra_vars[key], pk=None)
|
||||
job.extra_vars = json.dumps(extra_vars)
|
||||
changed = True
|
||||
if changed:
|
||||
job.save()
|
||||
|
||||
@@ -1,89 +1,9 @@
|
||||
import logging
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django.utils.text import slugify
|
||||
|
||||
from awx.main.models.base import PERM_INVENTORY_SCAN, PERM_INVENTORY_DEPLOY
|
||||
from awx.main import utils
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.migrations')
|
||||
|
||||
|
||||
def _create_fact_scan_project(ContentType, Project, org):
|
||||
ct = ContentType.objects.get_for_model(Project)
|
||||
name = u"Tower Fact Scan - {}".format(org.name if org else "No Organization")
|
||||
proj = Project(name=name,
|
||||
scm_url='https://github.com/ansible/awx-facts-playbooks',
|
||||
scm_type='git',
|
||||
scm_update_on_launch=True,
|
||||
scm_update_cache_timeout=86400,
|
||||
organization=org,
|
||||
created=now(),
|
||||
modified=now(),
|
||||
polymorphic_ctype=ct)
|
||||
proj.save()
|
||||
|
||||
slug_name = slugify(str(name)).replace(u'-', u'_')
|
||||
proj.local_path = u'_%d__%s' % (int(proj.pk), slug_name)
|
||||
|
||||
proj.save()
|
||||
return proj
|
||||
|
||||
|
||||
def _create_fact_scan_projects(ContentType, Project, orgs):
|
||||
return {org.id : _create_fact_scan_project(ContentType, Project, org) for org in orgs}
|
||||
|
||||
|
||||
def _get_tower_scan_job_templates(JobTemplate):
|
||||
return JobTemplate.objects.filter(job_type=PERM_INVENTORY_SCAN, project__isnull=True) \
|
||||
.prefetch_related('inventory__organization')
|
||||
|
||||
|
||||
def _get_orgs(Organization, job_template_ids):
|
||||
return Organization.objects.filter(inventories__jobtemplates__in=job_template_ids).distinct()
|
||||
|
||||
|
||||
def _migrate_scan_job_templates(apps):
|
||||
JobTemplate = apps.get_model('main', 'JobTemplate')
|
||||
Organization = apps.get_model('main', 'Organization')
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Project = apps.get_model('main', 'Project')
|
||||
|
||||
project_no_org = None
|
||||
|
||||
# A scan job template with a custom project will retain the custom project.
|
||||
JobTemplate.objects.filter(job_type=PERM_INVENTORY_SCAN, project__isnull=False).update(use_fact_cache=True, job_type=PERM_INVENTORY_DEPLOY)
|
||||
|
||||
# Scan jobs templates using Tower's default scan playbook will now point at
|
||||
# the same playbook but in a github repo.
|
||||
jts = _get_tower_scan_job_templates(JobTemplate)
|
||||
if jts.count() == 0:
|
||||
return
|
||||
|
||||
orgs = _get_orgs(Organization, jts.values_list('id'))
|
||||
if orgs.count() == 0:
|
||||
return
|
||||
|
||||
org_proj_map = _create_fact_scan_projects(ContentType, Project, orgs)
|
||||
for jt in jts:
|
||||
if jt.inventory and jt.inventory.organization:
|
||||
jt.project_id = org_proj_map[jt.inventory.organization.id].id
|
||||
# Job Templates without an Organization; through related Inventory
|
||||
else:
|
||||
if not project_no_org:
|
||||
project_no_org = _create_fact_scan_project(ContentType, Project, None)
|
||||
jt.project_id = project_no_org.id
|
||||
jt.job_type = PERM_INVENTORY_DEPLOY
|
||||
jt.playbook = "scan_facts.yml"
|
||||
jt.use_fact_cache = True
|
||||
jt.save()
|
||||
|
||||
|
||||
def migrate_scan_job_templates(apps, schema_editor):
|
||||
_migrate_scan_job_templates(apps)
|
||||
|
||||
|
||||
def remove_scan_type_nodes(apps, schema_editor):
|
||||
WorkflowJobTemplateNode = apps.get_model('main', 'WorkflowJobTemplateNode')
|
||||
WorkflowJobNode = apps.get_model('main', 'WorkflowJobNode')
|
||||
|
||||
@@ -270,6 +270,11 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin):
|
||||
.filter(capacity__gt=0, enabled=True)
|
||||
.values_list('hostname', flat=True)))
|
||||
|
||||
def set_default_policy_fields(self):
|
||||
self.policy_instance_list = []
|
||||
self.policy_instance_minimum = 0
|
||||
self.policy_instance_percentage = 0
|
||||
|
||||
|
||||
class TowerScheduleState(SingletonModel):
|
||||
schedule_last_run = models.DateTimeField(auto_now_add=True)
|
||||
@@ -289,6 +294,8 @@ def on_instance_group_saved(sender, instance, created=False, raw=False, **kwargs
|
||||
if created or instance.has_policy_changes():
|
||||
if not instance.is_containerized:
|
||||
schedule_policy_task()
|
||||
elif created or instance.is_containerized:
|
||||
instance.set_default_policy_fields()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Instance)
|
||||
|
||||
@@ -61,6 +61,7 @@ from awx.main.models.notifications import (
|
||||
)
|
||||
from awx.main.models.credential.injectors import _openstack_data
|
||||
from awx.main.utils import _inventory_updates, region_sorting, get_licenser
|
||||
from awx.main.utils.safe_yaml import sanitize_jinja
|
||||
|
||||
|
||||
__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate',
|
||||
@@ -754,6 +755,13 @@ class Host(CommonModelNameNotUnique, RelatedJobsMixin):
|
||||
update_host_smart_inventory_memberships.delay()
|
||||
connection.on_commit(on_commit)
|
||||
|
||||
def clean_name(self):
|
||||
try:
|
||||
sanitize_jinja(self.name)
|
||||
except ValueError as e:
|
||||
raise ValidationError(str(e) + ": {}".format(self.name))
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self._update_host_smart_inventory_memeberships()
|
||||
super(Host, self).save(*args, **kwargs)
|
||||
@@ -2036,9 +2044,25 @@ class azure_rm(PluginFileInjector):
|
||||
for key, loc in old_filterables:
|
||||
value = source_vars.get(key, None)
|
||||
if value and isinstance(value, str):
|
||||
user_filters.append('{} not in {}'.format(
|
||||
loc, value.split(',')
|
||||
))
|
||||
# tags can be list of key:value pairs
|
||||
# e.g. 'Creator:jmarshall, peanutbutter:jelly'
|
||||
# or tags can be a list of keys
|
||||
# e.g. 'Creator, peanutbutter'
|
||||
if key == "tags":
|
||||
# grab each key value pair
|
||||
for kvpair in value.split(','):
|
||||
# split into key and value
|
||||
kv = kvpair.split(':')
|
||||
# filter out any host that does not have key
|
||||
# in their tags.keys() variable
|
||||
user_filters.append('"{}" not in tags.keys()'.format(kv[0].strip()))
|
||||
# if a value is provided, check that the key:value pair matches
|
||||
if len(kv) > 1:
|
||||
user_filters.append('tags["{}"] != "{}"'.format(kv[0].strip(), kv[1].strip()))
|
||||
else:
|
||||
user_filters.append('{} not in {}'.format(
|
||||
loc, value.split(',')
|
||||
))
|
||||
if user_filters:
|
||||
ret.setdefault('exclude_host_filters', [])
|
||||
ret['exclude_host_filters'].extend(user_filters)
|
||||
|
||||
@@ -634,7 +634,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
else:
|
||||
# If for some reason we can't count the hosts then lets assume the impact as forks
|
||||
if self.inventory is not None:
|
||||
count_hosts = self.inventory.hosts.count()
|
||||
count_hosts = self.inventory.total_hosts
|
||||
if self.job_slice_count > 1:
|
||||
# Integer division intentional
|
||||
count_hosts = (count_hosts + self.job_slice_count - self.job_slice_number) // self.job_slice_count
|
||||
@@ -1106,8 +1106,8 @@ class SystemJobOptions(BaseModel):
|
||||
SYSTEM_JOB_TYPE = [
|
||||
('cleanup_jobs', _('Remove jobs older than a certain number of days')),
|
||||
('cleanup_activitystream', _('Remove activity stream entries older than a certain number of days')),
|
||||
('clearsessions', _('Removes expired browser sessions from the database')),
|
||||
('cleartokens', _('Removes expired OAuth 2 access tokens and refresh tokens'))
|
||||
('cleanup_sessions', _('Removes expired browser sessions from the database')),
|
||||
('cleanup_tokens', _('Removes expired OAuth 2 access tokens and refresh tokens'))
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@@ -1182,18 +1182,19 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions):
|
||||
for key in unallowed_vars:
|
||||
rejected[key] = data.pop(key)
|
||||
|
||||
if 'days' in data:
|
||||
try:
|
||||
if type(data['days']) is bool:
|
||||
raise ValueError
|
||||
if float(data['days']) != int(data['days']):
|
||||
raise ValueError
|
||||
days = int(data['days'])
|
||||
if days < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors_list.append(_("days must be a positive integer."))
|
||||
rejected['days'] = data.pop('days')
|
||||
if self.job_type in ('cleanup_jobs', 'cleanup_activitystream'):
|
||||
if 'days' in data:
|
||||
try:
|
||||
if isinstance(data['days'], (bool, type(None))):
|
||||
raise ValueError
|
||||
if float(data['days']) != int(data['days']):
|
||||
raise ValueError
|
||||
days = int(data['days'])
|
||||
if days < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors_list.append(_("days must be a positive integer."))
|
||||
rejected['days'] = data.pop('days')
|
||||
|
||||
if errors_list:
|
||||
errors['extra_vars'] = errors_list
|
||||
|
||||
@@ -269,6 +269,7 @@ class JobNotificationMixin(object):
|
||||
'timeout', 'use_fact_cache', 'launch_type', 'status', 'failed', 'started', 'finished',
|
||||
'elapsed', 'job_explanation', 'execution_node', 'controller_node', 'allow_simultaneous',
|
||||
'scm_revision', 'diff_mode', 'job_slice_number', 'job_slice_count', 'custom_virtualenv',
|
||||
'approval_status', 'approval_node_name', 'workflow_url',
|
||||
{'host_status_counts': ['skipped', 'ok', 'changed', 'failures', 'dark']},
|
||||
{'playbook_counts': ['play_count', 'task_count']},
|
||||
{'summary_fields': [{'inventory': ['id', 'name', 'description', 'has_active_failures',
|
||||
@@ -366,6 +367,9 @@ class JobNotificationMixin(object):
|
||||
'verbosity': 0},
|
||||
'job_friendly_name': 'Job',
|
||||
'url': 'https://towerhost/#/jobs/playbook/1010',
|
||||
'approval_status': 'approved',
|
||||
'approval_node_name': 'Approve Me',
|
||||
'workflow_url': 'https://towerhost/#/workflows/1010',
|
||||
'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13',
|
||||
'traceback': '',
|
||||
'status': 'running',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Python
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Django
|
||||
@@ -22,6 +23,9 @@ DATA_URI_RE = re.compile(r'.*') # FIXME
|
||||
__all__ = ['OAuth2AccessToken', 'OAuth2Application']
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.models.oauth')
|
||||
|
||||
|
||||
class OAuth2Application(AbstractApplication):
|
||||
|
||||
class Meta:
|
||||
@@ -120,15 +124,27 @@ class OAuth2AccessToken(AbstractAccessToken):
|
||||
def is_valid(self, scopes=None):
|
||||
valid = super(OAuth2AccessToken, self).is_valid(scopes)
|
||||
if valid:
|
||||
try:
|
||||
self.validate_external_users()
|
||||
except oauth2.AccessDeniedError:
|
||||
logger.exception(f'Failed to authenticate {self.user.username}')
|
||||
return False
|
||||
self.last_used = now()
|
||||
connection.on_commit(lambda: self.save(update_fields=['last_used']))
|
||||
|
||||
def _update_last_used():
|
||||
if OAuth2AccessToken.objects.filter(pk=self.pk).exists():
|
||||
self.save(update_fields=['last_used'])
|
||||
connection.on_commit(_update_last_used)
|
||||
return valid
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def validate_external_users(self):
|
||||
if self.user and settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS is False:
|
||||
external_account = get_external_account(self.user)
|
||||
if external_account is not None:
|
||||
raise oauth2.AccessDeniedError(_(
|
||||
'OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})'
|
||||
).format(external_account))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.validate_external_users()
|
||||
super(OAuth2AccessToken, self).save(*args, **kwargs)
|
||||
|
||||
@@ -483,6 +483,12 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage
|
||||
choices=PROJECT_UPDATE_JOB_TYPE_CHOICES,
|
||||
default='check',
|
||||
)
|
||||
job_tags = models.CharField(
|
||||
max_length=1024,
|
||||
blank=True,
|
||||
default='',
|
||||
help_text=_('Parts of the project update playbook that will be run.'),
|
||||
)
|
||||
scm_revision = models.CharField(
|
||||
max_length=1024,
|
||||
blank=True,
|
||||
@@ -587,3 +593,24 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage
|
||||
if not selected_groups:
|
||||
return self.global_instance_groups
|
||||
return selected_groups
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
added_update_fields = []
|
||||
if not self.job_tags:
|
||||
job_tags = ['update_{}'.format(self.scm_type)]
|
||||
if self.job_type == 'run':
|
||||
job_tags.append('install_roles')
|
||||
job_tags.append('install_collections')
|
||||
self.job_tags = ','.join(job_tags)
|
||||
added_update_fields.append('job_tags')
|
||||
if self.scm_delete_on_update and 'delete' not in self.job_tags and self.job_type == 'check':
|
||||
self.job_tags = ','.join([self.job_tags, 'delete'])
|
||||
added_update_fields.append('job_tags')
|
||||
elif (not self.scm_delete_on_update) and 'delete' in self.job_tags:
|
||||
job_tags = self.job_tags.split(',')
|
||||
job_tags.remove('delete')
|
||||
self.job_tags = ','.join(job_tags)
|
||||
added_update_fields.append('job_tags')
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'].extend(added_update_fields)
|
||||
return super(ProjectUpdate, self).save(*args, **kwargs)
|
||||
|
||||
@@ -6,15 +6,24 @@ class CustomNotificationBase(object):
|
||||
DEFAULT_MSG = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}"
|
||||
DEFAULT_BODY = "{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_metadata }}"
|
||||
|
||||
DEFAULT_APPROVAL_RUNNING_MSG = 'The approval node "{{ approval_node_name }}" needs review. This node can be viewed at: {{ workflow_url }}'
|
||||
DEFAULT_APPROVAL_RUNNING_BODY = ('The approval node "{{ approval_node_name }}" needs review. '
|
||||
'This approval node can be viewed at: {{ workflow_url }}\n\n{{ job_metadata }}')
|
||||
|
||||
DEFAULT_APPROVAL_APPROVED_MSG = 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}'
|
||||
DEFAULT_APPROVAL_APPROVED_BODY = 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}\n\n{{ job_metadata }}'
|
||||
|
||||
DEFAULT_APPROVAL_TIMEOUT_MSG = 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}'
|
||||
DEFAULT_APPROVAL_TIMEOUT_BODY = 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}\n\n{{ job_metadata }}'
|
||||
|
||||
DEFAULT_APPROVAL_DENIED_MSG = 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}'
|
||||
DEFAULT_APPROVAL_DENIED_BODY = 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}\n\n{{ job_metadata }}'
|
||||
|
||||
|
||||
default_messages = {"started": {"message": DEFAULT_MSG, "body": None},
|
||||
"success": {"message": DEFAULT_MSG, "body": None},
|
||||
"error": {"message": DEFAULT_MSG, "body": None},
|
||||
"workflow_approval": {"running": {"message": 'The approval node "{{ approval_node_name }}" needs review. '
|
||||
'This node can be viewed at: {{ workflow_url }}',
|
||||
"body": None},
|
||||
"approved": {"message": 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}',
|
||||
"body": None},
|
||||
"timed_out": {"message": 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}',
|
||||
"body": None},
|
||||
"denied": {"message": 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}',
|
||||
"body": None}}}
|
||||
"workflow_approval": {"running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": None},
|
||||
"approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": None},
|
||||
"timed_out": {"message": DEFAULT_APPROVAL_TIMEOUT_MSG, "body": None},
|
||||
"denied": {"message": DEFAULT_APPROVAL_DENIED_MSG, "body": None}}}
|
||||
|
||||
@@ -8,6 +8,18 @@ from awx.main.notifications.custom_notification_base import CustomNotificationBa
|
||||
DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG
|
||||
DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY
|
||||
|
||||
DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG
|
||||
DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY
|
||||
|
||||
DEFAULT_APPROVAL_APPROVED_MSG = CustomNotificationBase.DEFAULT_APPROVAL_APPROVED_MSG
|
||||
DEFAULT_APPROVAL_APPROVED_BODY = CustomNotificationBase.DEFAULT_APPROVAL_APPROVED_BODY
|
||||
|
||||
DEFAULT_APPROVAL_TIMEOUT_MSG = CustomNotificationBase.DEFAULT_APPROVAL_TIMEOUT_MSG
|
||||
DEFAULT_APPROVAL_TIMEOUT_BODY = CustomNotificationBase.DEFAULT_APPROVAL_TIMEOUT_BODY
|
||||
|
||||
DEFAULT_APPROVAL_DENIED_MSG = CustomNotificationBase.DEFAULT_APPROVAL_DENIED_MSG
|
||||
DEFAULT_APPROVAL_DENIED_BODY = CustomNotificationBase.DEFAULT_APPROVAL_DENIED_BODY
|
||||
|
||||
|
||||
class CustomEmailBackend(EmailBackend, CustomNotificationBase):
|
||||
|
||||
@@ -26,10 +38,10 @@ class CustomEmailBackend(EmailBackend, CustomNotificationBase):
|
||||
default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"approved": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY},
|
||||
"denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}}
|
||||
"workflow_approval": {"running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY},
|
||||
"approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY},
|
||||
"timed_out": {"message": DEFAULT_APPROVAL_TIMEOUT_MSG, "body": DEFAULT_APPROVAL_TIMEOUT_BODY},
|
||||
"denied": {"message": DEFAULT_APPROVAL_DENIED_MSG, "body": DEFAULT_APPROVAL_DENIED_BODY}}}
|
||||
|
||||
def format_body(self, body):
|
||||
# leave body unchanged (expect a string)
|
||||
|
||||
@@ -15,7 +15,6 @@ class DependencyGraph(object):
|
||||
INVENTORY_UPDATES = 'inventory_updates'
|
||||
|
||||
JOB_TEMPLATE_JOBS = 'job_template_jobs'
|
||||
JOB_PROJECT_IDS = 'job_project_ids'
|
||||
JOB_INVENTORY_IDS = 'job_inventory_ids'
|
||||
|
||||
SYSTEM_JOB = 'system_job'
|
||||
@@ -41,8 +40,6 @@ class DependencyGraph(object):
|
||||
Track runnable job related project and inventory to ensure updates
|
||||
don't run while a job needing those resources is running.
|
||||
'''
|
||||
# project_id -> True / False
|
||||
self.data[self.JOB_PROJECT_IDS] = {}
|
||||
# inventory_id -> True / False
|
||||
self.data[self.JOB_INVENTORY_IDS] = {}
|
||||
|
||||
@@ -66,7 +63,7 @@ class DependencyGraph(object):
|
||||
|
||||
def get_now(self):
|
||||
return tz_now()
|
||||
|
||||
|
||||
def mark_system_job(self):
|
||||
self.data[self.SYSTEM_JOB] = False
|
||||
|
||||
@@ -81,15 +78,13 @@ class DependencyGraph(object):
|
||||
|
||||
def mark_job_template_job(self, job):
|
||||
self.data[self.JOB_INVENTORY_IDS][job.inventory_id] = False
|
||||
self.data[self.JOB_PROJECT_IDS][job.project_id] = False
|
||||
self.data[self.JOB_TEMPLATE_JOBS][job.job_template_id] = False
|
||||
|
||||
def mark_workflow_job(self, job):
|
||||
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS][job.workflow_job_template_id] = False
|
||||
|
||||
def can_project_update_run(self, job):
|
||||
return self.data[self.JOB_PROJECT_IDS].get(job.project_id, True) and \
|
||||
self.data[self.PROJECT_UPDATES].get(job.project_id, True)
|
||||
return self.data[self.PROJECT_UPDATES].get(job.project_id, True)
|
||||
|
||||
def can_inventory_update_run(self, job):
|
||||
return self.data[self.JOB_INVENTORY_IDS].get(job.inventory_source.inventory_id, True) and \
|
||||
|
||||
@@ -173,7 +173,7 @@ def generate_tmp_kube_config(credential, namespace):
|
||||
"current-context": host_input
|
||||
}
|
||||
|
||||
if credential.get_input('verify_ssl'):
|
||||
if credential.get_input('verify_ssl') and 'ssl_ca_cert' in credential.inputs:
|
||||
config["clusters"][0]["cluster"]["certificate-authority-data"] = b64encode(
|
||||
credential.get_input('ssl_ca_cert').encode() # encode to bytes
|
||||
).decode() # decode the base64 data into a str
|
||||
|
||||
@@ -258,19 +258,24 @@ class TaskManager():
|
||||
for group in InstanceGroup.objects.all():
|
||||
if group.is_containerized or group.controller_id:
|
||||
continue
|
||||
match = group.find_largest_idle_instance()
|
||||
match = group.fit_task_to_most_remaining_capacity_instance(task)
|
||||
if match:
|
||||
break
|
||||
task.instance_group = rampart_group
|
||||
if task.supports_isolation():
|
||||
task.controller_node = match.hostname
|
||||
if match is None:
|
||||
logger.warn(
|
||||
'No available capacity to run containerized <{}>.'.format(task.log_format)
|
||||
)
|
||||
else:
|
||||
# project updates and inventory updates don't *actually* run in pods,
|
||||
# so just pick *any* non-isolated, non-containerized host and use it
|
||||
# as the execution node
|
||||
task.execution_node = match.hostname
|
||||
logger.debug('Submitting containerized {} to queue {}.'.format(
|
||||
task.log_format, task.execution_node))
|
||||
if task.supports_isolation():
|
||||
task.controller_node = match.hostname
|
||||
else:
|
||||
# project updates and inventory updates don't *actually* run in pods,
|
||||
# so just pick *any* non-isolated, non-containerized host and use it
|
||||
# as the execution node
|
||||
task.execution_node = match.hostname
|
||||
logger.debug('Submitting containerized {} to queue {}.'.format(
|
||||
task.log_format, task.execution_node))
|
||||
else:
|
||||
task.instance_group = rampart_group
|
||||
if instance is not None:
|
||||
|
||||
@@ -5,11 +5,15 @@ import logging
|
||||
# AWX
|
||||
from awx.main.scheduler import TaskManager
|
||||
from awx.main.dispatch.publish import task
|
||||
from awx.main.utils.db import migration_in_progress_check_or_relase
|
||||
|
||||
logger = logging.getLogger('awx.main.scheduler')
|
||||
|
||||
|
||||
@task()
|
||||
def run_task_manager():
|
||||
if migration_in_progress_check_or_relase():
|
||||
logger.debug("Not running task manager because migration is in progress.")
|
||||
return
|
||||
logger.debug("Running Tower task manager.")
|
||||
TaskManager().schedule()
|
||||
|
||||
@@ -71,7 +71,7 @@ from awx.main.utils import (get_ssh_version, update_scm_url,
|
||||
ignore_inventory_group_removal, extract_ansible_vars, schedule_task_manager,
|
||||
get_awx_version)
|
||||
from awx.main.utils.ansible import read_ansible_config
|
||||
from awx.main.utils.common import get_ansible_version, _get_ansible_version, get_custom_venv_choices
|
||||
from awx.main.utils.common import _get_ansible_version, get_custom_venv_choices
|
||||
from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja
|
||||
from awx.main.utils.reload import stop_local_services
|
||||
from awx.main.utils.pglock import advisory_lock
|
||||
@@ -263,6 +263,12 @@ def apply_cluster_membership_policies():
|
||||
logger.debug('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
|
||||
|
||||
|
||||
@task(queue='tower_broadcast_all', exchange_type='fanout')
|
||||
def set_migration_flag():
|
||||
logger.debug('Received migration-in-progress signal, will serve redirect.')
|
||||
cache.set('migration_in_progress', True)
|
||||
|
||||
|
||||
@task(queue='tower_broadcast_all', exchange_type='fanout')
|
||||
def handle_setting_changes(setting_keys):
|
||||
orig_len = len(setting_keys)
|
||||
@@ -1336,7 +1342,7 @@ class BaseTask(object):
|
||||
|
||||
ansible_runner.utils.dump_artifacts(params)
|
||||
isolated_manager_instance = isolated_manager.IsolatedManager(
|
||||
cancelled_callback=lambda: self.update_model(self.instance.pk).cancel_flag,
|
||||
canceled_callback=lambda: self.update_model(self.instance.pk).cancel_flag,
|
||||
check_callback=self.check_handler,
|
||||
pod_manager=pod_manager
|
||||
)
|
||||
@@ -1734,14 +1740,16 @@ class RunJob(BaseTask):
|
||||
|
||||
project_path = job.project.get_project_path(check_if_exists=False)
|
||||
job_revision = job.project.scm_revision
|
||||
needs_sync = True
|
||||
sync_needs = []
|
||||
all_sync_needs = ['update_{}'.format(job.project.scm_type), 'install_roles', 'install_collections']
|
||||
if not job.project.scm_type:
|
||||
# manual projects are not synced, user has responsibility for that
|
||||
needs_sync = False
|
||||
pass # manual projects are not synced, user has responsibility for that
|
||||
elif not os.path.exists(project_path):
|
||||
logger.debug('Performing fresh clone of {} on this instance.'.format(job.project))
|
||||
sync_needs = all_sync_needs
|
||||
elif not job.project.scm_revision:
|
||||
logger.debug('Revision not known for {}, will sync with remote'.format(job.project))
|
||||
sync_needs = all_sync_needs
|
||||
elif job.project.scm_type == 'git':
|
||||
git_repo = git.Repo(project_path)
|
||||
try:
|
||||
@@ -1752,23 +1760,27 @@ class RunJob(BaseTask):
|
||||
if desired_revision == current_revision:
|
||||
job_revision = desired_revision
|
||||
logger.info('Skipping project sync for {} because commit is locally available'.format(job.log_format))
|
||||
needs_sync = False
|
||||
else:
|
||||
sync_needs = all_sync_needs
|
||||
except (ValueError, BadGitName):
|
||||
logger.debug('Needed commit for {} not in local source tree, will sync with remote'.format(job.log_format))
|
||||
sync_needs = all_sync_needs
|
||||
else:
|
||||
sync_needs = all_sync_needs
|
||||
# Galaxy requirements are not supported for manual projects
|
||||
if not needs_sync and job.project.scm_type:
|
||||
if not sync_needs and job.project.scm_type:
|
||||
# see if we need a sync because of presence of roles
|
||||
galaxy_req_path = os.path.join(project_path, 'roles', 'requirements.yml')
|
||||
if os.path.exists(galaxy_req_path):
|
||||
logger.debug('Running project sync for {} because of galaxy role requirements.'.format(job.log_format))
|
||||
needs_sync = True
|
||||
sync_needs.append('install_roles')
|
||||
|
||||
galaxy_collections_req_path = os.path.join(project_path, 'collections', 'requirements.yml')
|
||||
if os.path.exists(galaxy_collections_req_path):
|
||||
logger.debug('Running project sync for {} because of galaxy collections requirements.'.format(job.log_format))
|
||||
needs_sync = True
|
||||
sync_needs.append('install_collections')
|
||||
|
||||
if needs_sync:
|
||||
if sync_needs:
|
||||
pu_ig = job.instance_group
|
||||
pu_en = job.execution_node
|
||||
if job.is_isolated() is True:
|
||||
@@ -1778,6 +1790,7 @@ class RunJob(BaseTask):
|
||||
sync_metafields = dict(
|
||||
launch_type="sync",
|
||||
job_type='run',
|
||||
job_tags=','.join(sync_needs),
|
||||
status='running',
|
||||
instance_group = pu_ig,
|
||||
execution_node=pu_en,
|
||||
@@ -1785,6 +1798,8 @@ class RunJob(BaseTask):
|
||||
)
|
||||
if job.scm_branch and job.scm_branch != job.project.scm_branch:
|
||||
sync_metafields['scm_branch'] = job.scm_branch
|
||||
if 'update_' not in sync_metafields['job_tags']:
|
||||
sync_metafields['scm_revision'] = job_revision
|
||||
local_project_sync = job.project.create_project_update(_eager_fields=sync_metafields)
|
||||
# save the associated job before calling run() so that a
|
||||
# cancel() call on the job can cancel the project update
|
||||
@@ -1929,7 +1944,8 @@ class RunProjectUpdate(BaseTask):
|
||||
env['TMP'] = settings.AWX_PROOT_BASE_PATH
|
||||
env['PROJECT_UPDATE_ID'] = str(project_update.pk)
|
||||
env['ANSIBLE_CALLBACK_PLUGINS'] = self.get_path_to('..', 'plugins', 'callback')
|
||||
env['ANSIBLE_GALAXY_IGNORE'] = True
|
||||
if settings.GALAXY_IGNORE_CERTS:
|
||||
env['ANSIBLE_GALAXY_IGNORE'] = True
|
||||
# Set up the public Galaxy server, if enabled
|
||||
if settings.PUBLIC_GALAXY_ENABLED:
|
||||
galaxy_servers = [settings.PUBLIC_GALAXY_SERVER]
|
||||
@@ -2008,8 +2024,8 @@ class RunProjectUpdate(BaseTask):
|
||||
args = []
|
||||
if getattr(settings, 'PROJECT_UPDATE_VVV', False):
|
||||
args.append('-vvv')
|
||||
else:
|
||||
args.append('-v')
|
||||
if project_update.job_tags:
|
||||
args.extend(['-t', project_update.job_tags])
|
||||
return args
|
||||
|
||||
def build_extra_vars_file(self, project_update, private_data_dir):
|
||||
@@ -2023,28 +2039,16 @@ class RunProjectUpdate(BaseTask):
|
||||
scm_branch = project_update.project.scm_revision
|
||||
elif not scm_branch:
|
||||
scm_branch = {'hg': 'tip'}.get(project_update.scm_type, 'HEAD')
|
||||
if project_update.job_type == 'check':
|
||||
roles_enabled = False
|
||||
collections_enabled = False
|
||||
else:
|
||||
roles_enabled = getattr(settings, 'AWX_ROLES_ENABLED', True)
|
||||
collections_enabled = getattr(settings, 'AWX_COLLECTIONS_ENABLED', True)
|
||||
# collections were introduced in Ansible version 2.8
|
||||
if Version(get_ansible_version()) <= Version('2.8'):
|
||||
collections_enabled = False
|
||||
extra_vars.update({
|
||||
'project_path': project_update.get_project_path(check_if_exists=False),
|
||||
'insights_url': settings.INSIGHTS_URL_BASE,
|
||||
'awx_license_type': get_license(show_key=False).get('license_type', 'UNLICENSED'),
|
||||
'awx_version': get_awx_version(),
|
||||
'scm_type': project_update.scm_type,
|
||||
'scm_url': scm_url,
|
||||
'scm_branch': scm_branch,
|
||||
'scm_clean': project_update.scm_clean,
|
||||
'scm_delete_on_update': project_update.scm_delete_on_update if project_update.job_type == 'check' else False,
|
||||
'scm_full_checkout': True if project_update.job_type == 'run' else False,
|
||||
'roles_enabled': roles_enabled,
|
||||
'collections_enabled': collections_enabled,
|
||||
'roles_enabled': settings.AWX_ROLES_ENABLED,
|
||||
'collections_enabled': settings.AWX_COLLECTIONS_ENABLED,
|
||||
})
|
||||
if project_update.job_type != 'check' and self.job_private_data_dir:
|
||||
extra_vars['collections_destination'] = os.path.join(self.job_private_data_dir, 'requirements_collections')
|
||||
@@ -2156,7 +2160,7 @@ class RunProjectUpdate(BaseTask):
|
||||
try:
|
||||
instance.refresh_from_db(fields=['cancel_flag'])
|
||||
if instance.cancel_flag:
|
||||
logger.debug("ProjectUpdate({0}) was cancelled".format(instance.pk))
|
||||
logger.debug("ProjectUpdate({0}) was canceled".format(instance.pk))
|
||||
return
|
||||
fcntl.lockf(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
@@ -2185,7 +2189,10 @@ class RunProjectUpdate(BaseTask):
|
||||
project_path = instance.project.get_project_path(check_if_exists=False)
|
||||
if os.path.exists(project_path):
|
||||
git_repo = git.Repo(project_path)
|
||||
self.original_branch = git_repo.active_branch
|
||||
if git_repo.head.is_detached:
|
||||
self.original_branch = git_repo.head.commit
|
||||
else:
|
||||
self.original_branch = git_repo.active_branch
|
||||
|
||||
@staticmethod
|
||||
def make_local_copy(project_path, destination_folder, scm_type, scm_revision):
|
||||
@@ -2217,26 +2224,29 @@ class RunProjectUpdate(BaseTask):
|
||||
copy_tree(project_path, destination_folder)
|
||||
|
||||
def post_run_hook(self, instance, status):
|
||||
if self.job_private_data_dir:
|
||||
# copy project folder before resetting to default branch
|
||||
# because some git-tree-specific resources (like submodules) might matter
|
||||
self.make_local_copy(
|
||||
instance.get_project_path(check_if_exists=False), os.path.join(self.job_private_data_dir, 'project'),
|
||||
instance.scm_type, self.playbook_new_revision
|
||||
)
|
||||
if self.original_branch:
|
||||
# for git project syncs, non-default branches can be problems
|
||||
# restore to branch the repo was on before this run
|
||||
try:
|
||||
self.original_branch.checkout()
|
||||
except Exception:
|
||||
# this could have failed due to dirty tree, but difficult to predict all cases
|
||||
logger.exception('Failed to restore project repo to prior state after {}'.format(instance.log_format))
|
||||
self.release_lock(instance)
|
||||
# To avoid hangs, very important to release lock even if errors happen here
|
||||
try:
|
||||
if self.playbook_new_revision:
|
||||
instance.scm_revision = self.playbook_new_revision
|
||||
instance.save(update_fields=['scm_revision'])
|
||||
if self.job_private_data_dir:
|
||||
# copy project folder before resetting to default branch
|
||||
# because some git-tree-specific resources (like submodules) might matter
|
||||
self.make_local_copy(
|
||||
instance.get_project_path(check_if_exists=False), os.path.join(self.job_private_data_dir, 'project'),
|
||||
instance.scm_type, instance.scm_revision
|
||||
)
|
||||
if self.original_branch:
|
||||
# for git project syncs, non-default branches can be problems
|
||||
# restore to branch the repo was on before this run
|
||||
try:
|
||||
self.original_branch.checkout()
|
||||
except Exception:
|
||||
# this could have failed due to dirty tree, but difficult to predict all cases
|
||||
logger.exception('Failed to restore project repo to prior state after {}'.format(instance.log_format))
|
||||
finally:
|
||||
self.release_lock(instance)
|
||||
p = instance.project
|
||||
if self.playbook_new_revision:
|
||||
instance.scm_revision = self.playbook_new_revision
|
||||
instance.save(update_fields=['scm_revision'])
|
||||
if instance.job_type == 'check' and status not in ('failed', 'canceled',):
|
||||
if self.playbook_new_revision:
|
||||
p.scm_revision = self.playbook_new_revision
|
||||
@@ -2497,6 +2507,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
_eager_fields=dict(
|
||||
launch_type="sync",
|
||||
job_type='run',
|
||||
job_tags='update_{},install_collections'.format(source_project.scm_type), # roles are never valid for inventory
|
||||
status='running',
|
||||
execution_node=inventory_update.execution_node,
|
||||
instance_group = inventory_update.instance_group,
|
||||
@@ -2732,10 +2743,11 @@ class RunSystemJob(BaseTask):
|
||||
json_vars = {}
|
||||
else:
|
||||
json_vars = json.loads(system_job.extra_vars)
|
||||
if 'days' in json_vars:
|
||||
args.extend(['--days', str(json_vars.get('days', 60))])
|
||||
if 'dry_run' in json_vars and json_vars['dry_run']:
|
||||
args.extend(['--dry-run'])
|
||||
if system_job.job_type in ('cleanup_jobs', 'cleanup_activitystream'):
|
||||
if 'days' in json_vars:
|
||||
args.extend(['--days', str(json_vars.get('days', 60))])
|
||||
if 'dry_run' in json_vars and json_vars['dry_run']:
|
||||
args.extend(['--dry-run'])
|
||||
if system_job.job_type == 'cleanup_jobs':
|
||||
args.extend(['--jobs', '--project-updates', '--inventory-updates',
|
||||
'--management-jobs', '--ad-hoc-commands', '--workflow-jobs',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
- name: Hello World Sample
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Hello Message
|
||||
debug:
|
||||
msg: "Hello World!"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
- name: Hello World Sample
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Hello Message
|
||||
debug:
|
||||
msg: "Hello World!"
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
---
|
||||
- hosts: all
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
---
|
||||
- import_playbook: foo
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
---
|
||||
- include: foo
|
||||
|
||||
@@ -3,6 +3,10 @@ conditional_groups:
|
||||
default_host_filters: []
|
||||
exclude_host_filters:
|
||||
- resource_group not in ['foo_resources', 'bar_resources']
|
||||
- '"Creator" not in tags.keys()'
|
||||
- tags["Creator"] != "jmarshall"
|
||||
- '"peanutbutter" not in tags.keys()'
|
||||
- tags["peanutbutter"] != "jelly"
|
||||
- location not in ['southcentralus', 'westus']
|
||||
fail_on_template_errors: false
|
||||
hostvar_expressions:
|
||||
|
||||
@@ -7,4 +7,5 @@ locations = southcentralus,westus
|
||||
base_source_var = value_of_var
|
||||
use_private_ip = True
|
||||
resource_groups = foo_resources,bar_resources
|
||||
tags = Creator:jmarshall, peanutbutter:jelly
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ EXPECTED_VALUES = {
|
||||
'awx_custom_virtualenvs_total':0.0,
|
||||
'awx_running_jobs_total':0.0,
|
||||
'awx_instance_capacity':100.0,
|
||||
'awx_instance_consumed_capacity':0.0,
|
||||
'awx_instance_remaining_capacity':100.0,
|
||||
'awx_instance_cpu':0.0,
|
||||
'awx_instance_memory':0.0,
|
||||
'awx_instance_info':1.0,
|
||||
|
||||
@@ -1439,3 +1439,15 @@ def test_create_credential_with_invalid_url_xfail(post, organization, admin, url
|
||||
assert response.status_code == status
|
||||
if status != 201:
|
||||
assert response.data['inputs']['server_url'] == [msg]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_external_credential_rbac_test_endpoint(post, alice, external_credential):
|
||||
url = reverse('api:credential_external_test', kwargs={'pk': external_credential.pk})
|
||||
data = {'metadata': {'key': 'some_key'}}
|
||||
|
||||
external_credential.read_role.members.add(alice)
|
||||
assert post(url, data, alice).status_code == 403
|
||||
|
||||
external_credential.use_role.members.add(alice)
|
||||
assert post(url, data, alice).status_code == 202
|
||||
|
||||
@@ -85,14 +85,35 @@ def test_update_credential_type_in_use_xfail(patch, delete, admin):
|
||||
Credential(credential_type=_type, name='My Custom Cred').save()
|
||||
|
||||
url = reverse('api:credential_type_detail', kwargs={'pk': _type.pk})
|
||||
response = patch(url, {'name': 'Some Other Name'}, admin)
|
||||
assert response.status_code == 200
|
||||
patch(url, {'name': 'Some Other Name'}, admin, expect=200)
|
||||
|
||||
url = reverse('api:credential_type_detail', kwargs={'pk': _type.pk})
|
||||
response = patch(url, {'inputs': {}}, admin)
|
||||
assert response.status_code == 403
|
||||
response = patch(url, {'inputs': {}}, admin, expect=403)
|
||||
assert response.data['detail'] == 'Modifications to inputs are not allowed for credential types that are in use'
|
||||
|
||||
assert delete(url, admin).status_code == 403
|
||||
response = delete(url, admin, expect=403)
|
||||
assert response.data['detail'] == 'Credential types that are in use cannot be deleted'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_credential_type_unvalidated_inputs(post, patch, admin):
|
||||
simple_inputs = {'fields': [
|
||||
{'id': 'api_token', 'label': 'fooo'}
|
||||
]}
|
||||
response = post(
|
||||
url=reverse('api:credential_type_list'),
|
||||
data={'name': 'foo', 'kind': 'cloud', 'inputs': simple_inputs},
|
||||
user=admin,
|
||||
expect=201
|
||||
)
|
||||
# validation adds the type field to the input
|
||||
_type = CredentialType.objects.get(pk=response.data['id'])
|
||||
Credential(credential_type=_type, name='My Custom Cred').save()
|
||||
|
||||
# should not raise an error because we should only compare
|
||||
# post-validation values to other post-validation values
|
||||
url = reverse('api:credential_type_detail', kwargs={'pk': _type.id})
|
||||
patch(url, {'inputs': simple_inputs}, admin, expect=200)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -460,3 +481,12 @@ def test_create_with_undefined_template_variable_xfail(post, admin):
|
||||
}, admin)
|
||||
assert response.status_code == 400
|
||||
assert "'api_tolkien' is undefined" in json.dumps(response.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_credential_type_rbac_external_test(post, alice, admin, credentialtype_external):
|
||||
# only admins may use the credential type test endpoint
|
||||
url = reverse('api:credential_type_external_test', kwargs={'pk': credentialtype_external.pk})
|
||||
data = {'inputs': {}, 'metadata': {}}
|
||||
assert post(url, data, admin).status_code == 202
|
||||
assert post(url, data, alice).status_code == 403
|
||||
|
||||
@@ -274,3 +274,21 @@ def test_instance_group_update_fields(patch, instance, instance_group, admin, co
|
||||
assert ["Containerized instances may not be managed via the API"] == resp.data['policy_instance_minimum']
|
||||
resp = patch(cg_url, {'policy_instance_list':[instance.hostname]}, admin)
|
||||
assert ["Containerized instances may not be managed via the API"] == resp.data['policy_instance_list']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_containerized_group_default_fields(instance_group, kube_credential):
|
||||
ig = InstanceGroup(name="test_policy_field_defaults")
|
||||
ig.policy_instance_list = [1]
|
||||
ig.policy_instance_minimum = 5
|
||||
ig.policy_instance_percentage = 5
|
||||
ig.save()
|
||||
assert ig.policy_instance_list == [1]
|
||||
assert ig.policy_instance_minimum == 5
|
||||
assert ig.policy_instance_percentage == 5
|
||||
ig.credential = kube_credential
|
||||
ig.save()
|
||||
assert ig.policy_instance_list == []
|
||||
assert ig.policy_instance_minimum == 0
|
||||
assert ig.policy_instance_percentage == 0
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.db import connection
|
||||
from django.test.utils import override_settings
|
||||
@@ -14,6 +16,18 @@ from awx.sso.models import UserEnterpriseAuth
|
||||
from oauth2_provider.models import RefreshToken
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def immediate_on_commit():
|
||||
"""
|
||||
Context manager executing transaction.on_commit() hooks immediately as
|
||||
if the connection was in auto-commit mode.
|
||||
"""
|
||||
def on_commit(func):
|
||||
func()
|
||||
with mock.patch('django.db.connection.on_commit', side_effect=on_commit) as patch:
|
||||
yield patch
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_personal_access_token_creation(oauth_application, post, alice):
|
||||
url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
|
||||
@@ -54,6 +68,41 @@ def test_token_creation_disabled_for_external_accounts(oauth_application, post,
|
||||
assert AccessToken.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_existing_token_disabled_for_external_accounts(oauth_application, get, post, admin):
|
||||
UserEnterpriseAuth(user=admin, provider='radius').save()
|
||||
url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
|
||||
with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=True):
|
||||
resp = post(
|
||||
url,
|
||||
data='grant_type=password&username=admin&password=admin&scope=read',
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([
|
||||
oauth_application.client_id, oauth_application.client_secret
|
||||
])))),
|
||||
status=201
|
||||
)
|
||||
token = json.loads(resp.content)['access_token']
|
||||
assert AccessToken.objects.count() == 1
|
||||
|
||||
with immediate_on_commit():
|
||||
resp = get(
|
||||
drf_reverse('api:user_me_list', kwargs={'version': 'v2'}),
|
||||
HTTP_AUTHORIZATION='Bearer ' + token,
|
||||
status=200
|
||||
)
|
||||
assert json.loads(resp.content)['results'][0]['username'] == 'admin'
|
||||
|
||||
with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USER=False):
|
||||
with immediate_on_commit():
|
||||
resp = get(
|
||||
drf_reverse('api:user_me_list', kwargs={'version': 'v2'}),
|
||||
HTTP_AUTHORIZATION='Bearer ' + token,
|
||||
status=401
|
||||
)
|
||||
assert b'To establish a login session' in resp.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_pat_creation_no_default_scope(oauth_application, post, admin):
|
||||
# tests that the default scope is overriden
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import json
|
||||
|
||||
from cryptography.fernet import InvalidToken
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
import pytest
|
||||
|
||||
from awx.main import models
|
||||
from awx.conf.models import Setting
|
||||
from awx.main.management.commands import regenerate_secret_key
|
||||
from awx.main.utils.encryption import encrypt_field, decrypt_field, encrypt_value
|
||||
|
||||
|
||||
PREFIX = '$encrypted$UTF8$AESCBC$'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestKeyRegeneration:
|
||||
|
||||
def test_encrypted_ssh_password(self, credential):
|
||||
# test basic decryption
|
||||
assert credential.inputs['password'].startswith(PREFIX)
|
||||
assert credential.get_input('password') == 'secret'
|
||||
|
||||
# re-key the credential
|
||||
new_key = regenerate_secret_key.Command().handle()
|
||||
new_cred = models.Credential.objects.get(pk=credential.pk)
|
||||
assert credential.inputs['password'] != new_cred.inputs['password']
|
||||
|
||||
# verify that the old SECRET_KEY doesn't work
|
||||
with pytest.raises(InvalidToken):
|
||||
new_cred.get_input('password')
|
||||
|
||||
# verify that the new SECRET_KEY *does* work
|
||||
with override_settings(SECRET_KEY=new_key):
|
||||
assert new_cred.get_input('password') == 'secret'
|
||||
|
||||
def test_encrypted_setting_values(self):
|
||||
# test basic decryption
|
||||
settings.LOG_AGGREGATOR_PASSWORD = 'sensitive'
|
||||
s = Setting.objects.filter(key='LOG_AGGREGATOR_PASSWORD').first()
|
||||
assert s.value.startswith(PREFIX)
|
||||
assert settings.LOG_AGGREGATOR_PASSWORD == 'sensitive'
|
||||
|
||||
# re-key the setting value
|
||||
new_key = regenerate_secret_key.Command().handle()
|
||||
new_setting = Setting.objects.filter(key='LOG_AGGREGATOR_PASSWORD').first()
|
||||
assert s.value != new_setting.value
|
||||
|
||||
# wipe out the local cache so the value is pulled from the DB again
|
||||
settings.cache.delete('LOG_AGGREGATOR_PASSWORD')
|
||||
|
||||
# verify that the old SECRET_KEY doesn't work
|
||||
with pytest.raises(InvalidToken):
|
||||
settings.LOG_AGGREGATOR_PASSWORD
|
||||
|
||||
# verify that the new SECRET_KEY *does* work
|
||||
with override_settings(SECRET_KEY=new_key):
|
||||
assert settings.LOG_AGGREGATOR_PASSWORD == 'sensitive'
|
||||
|
||||
def test_encrypted_notification_secrets(self, notification_template_with_encrypt):
|
||||
# test basic decryption
|
||||
nt = notification_template_with_encrypt
|
||||
nc = nt.notification_configuration
|
||||
assert nc['token'].startswith(PREFIX)
|
||||
|
||||
Slack = nt.CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type]
|
||||
class TestBackend(Slack):
|
||||
|
||||
def __init__(self, *args, **kw):
|
||||
assert kw['token'] == 'token'
|
||||
|
||||
def send_messages(self, messages):
|
||||
pass
|
||||
|
||||
nt.CLASS_FOR_NOTIFICATION_TYPE['test'] = TestBackend
|
||||
nt.notification_type = 'test'
|
||||
nt.send('Subject', 'Body')
|
||||
|
||||
# re-key the notification config
|
||||
new_key = regenerate_secret_key.Command().handle()
|
||||
new_nt = models.NotificationTemplate.objects.get(pk=nt.pk)
|
||||
assert nt.notification_configuration['token'] != new_nt.notification_configuration['token']
|
||||
|
||||
# verify that the old SECRET_KEY doesn't work
|
||||
with pytest.raises(InvalidToken):
|
||||
new_nt.CLASS_FOR_NOTIFICATION_TYPE['test'] = TestBackend
|
||||
new_nt.notification_type = 'test'
|
||||
new_nt.send('Subject', 'Body')
|
||||
|
||||
# verify that the new SECRET_KEY *does* work
|
||||
with override_settings(SECRET_KEY=new_key):
|
||||
new_nt.send('Subject', 'Body')
|
||||
|
||||
def test_job_start_args(self, job_factory):
|
||||
# test basic decryption
|
||||
job = job_factory()
|
||||
job.start_args = json.dumps({'foo': 'bar'})
|
||||
job.start_args = encrypt_field(job, field_name='start_args')
|
||||
job.save()
|
||||
assert job.start_args.startswith(PREFIX)
|
||||
|
||||
# re-key the start_args
|
||||
new_key = regenerate_secret_key.Command().handle()
|
||||
new_job = models.Job.objects.get(pk=job.pk)
|
||||
assert new_job.start_args != job.start_args
|
||||
|
||||
# verify that the old SECRET_KEY doesn't work
|
||||
with pytest.raises(InvalidToken):
|
||||
decrypt_field(new_job, field_name='start_args')
|
||||
|
||||
# verify that the new SECRET_KEY *does* work
|
||||
with override_settings(SECRET_KEY=new_key):
|
||||
assert json.loads(
|
||||
decrypt_field(new_job, field_name='start_args')
|
||||
) == {'foo': 'bar'}
|
||||
|
||||
@pytest.mark.parametrize('cls', ('JobTemplate', 'WorkflowJobTemplate'))
|
||||
def test_survey_spec(self, inventory, project, survey_spec_factory, cls):
|
||||
params = {}
|
||||
if cls == 'JobTemplate':
|
||||
params['inventory'] = inventory
|
||||
params['project'] = project
|
||||
# test basic decryption
|
||||
jt = getattr(models, cls).objects.create(
|
||||
name='Example Template',
|
||||
survey_spec=survey_spec_factory([{
|
||||
'variable': 'secret_key',
|
||||
'default': encrypt_value('donttell', pk=None),
|
||||
'type': 'password'
|
||||
}]),
|
||||
survey_enabled=True,
|
||||
**params
|
||||
)
|
||||
job = jt.create_unified_job()
|
||||
assert jt.survey_spec['spec'][0]['default'].startswith(PREFIX)
|
||||
assert job.survey_passwords == {'secret_key': '$encrypted$'}
|
||||
assert json.loads(job.decrypted_extra_vars())['secret_key'] == 'donttell'
|
||||
|
||||
# re-key the extra_vars
|
||||
new_key = regenerate_secret_key.Command().handle()
|
||||
new_job = models.UnifiedJob.objects.get(pk=job.pk)
|
||||
assert new_job.extra_vars != job.extra_vars
|
||||
|
||||
# verify that the old SECRET_KEY doesn't work
|
||||
with pytest.raises(InvalidToken):
|
||||
new_job.decrypted_extra_vars()
|
||||
|
||||
# verify that the new SECRET_KEY *does* work
|
||||
with override_settings(SECRET_KEY=new_key):
|
||||
assert json.loads(
|
||||
new_job.decrypted_extra_vars()
|
||||
)['secret_key'] == 'donttell'
|
||||
|
||||
def test_oauth2_application_client_secret(self, oauth_application):
|
||||
# test basic decryption
|
||||
secret = oauth_application.client_secret
|
||||
assert len(secret) == 128
|
||||
|
||||
# re-key the client_secret
|
||||
new_key = regenerate_secret_key.Command().handle()
|
||||
|
||||
# verify that the old SECRET_KEY doesn't work
|
||||
with pytest.raises(InvalidToken):
|
||||
models.OAuth2Application.objects.get(
|
||||
pk=oauth_application.pk
|
||||
).client_secret
|
||||
|
||||
# verify that the new SECRET_KEY *does* work
|
||||
with override_settings(SECRET_KEY=new_key):
|
||||
assert models.OAuth2Application.objects.get(
|
||||
pk=oauth_application.pk
|
||||
).client_secret == secret
|
||||
@@ -122,6 +122,22 @@ def project_playbooks():
|
||||
mocked.start()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_computed_fields_right_away(request):
|
||||
|
||||
def run_me(inventory_id, should_update_hosts=True):
|
||||
i = Inventory.objects.get(id=inventory_id)
|
||||
i.update_computed_fields(update_hosts=should_update_hosts)
|
||||
|
||||
mocked = mock.patch(
|
||||
'awx.main.signals.update_inventory_computed_fields.delay',
|
||||
new=run_me
|
||||
)
|
||||
mocked.start()
|
||||
|
||||
request.addfinalizer(mocked.stop)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@mock.patch.object(Project, "update", lambda self, **kwargs: None)
|
||||
def project(instance, organization):
|
||||
@@ -280,14 +296,21 @@ def credentialtype_external():
|
||||
}],
|
||||
'required': ['url', 'token', 'key'],
|
||||
}
|
||||
external_type = CredentialType(
|
||||
kind='external',
|
||||
managed_by_tower=True,
|
||||
name='External Service',
|
||||
inputs=external_type_inputs
|
||||
)
|
||||
external_type.save()
|
||||
return external_type
|
||||
|
||||
class MockPlugin(object):
|
||||
def backend(self, **kwargs):
|
||||
return 'secret'
|
||||
|
||||
with mock.patch('awx.main.models.credential.CredentialType.plugin', new_callable=PropertyMock) as mock_plugin:
|
||||
mock_plugin.return_value = MockPlugin()
|
||||
external_type = CredentialType(
|
||||
kind='external',
|
||||
managed_by_tower=True,
|
||||
name='External Service',
|
||||
inputs=external_type_inputs
|
||||
)
|
||||
external_type.save()
|
||||
yield external_type
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -88,6 +88,9 @@ class TestJobNotificationMixin(object):
|
||||
'verbosity': int},
|
||||
'job_friendly_name': str,
|
||||
'job_metadata': str,
|
||||
'approval_status': str,
|
||||
'approval_node_name': str,
|
||||
'workflow_url': str,
|
||||
'url': str}
|
||||
|
||||
|
||||
|
||||
@@ -281,15 +281,18 @@ class TestTaskImpact:
|
||||
return job
|
||||
return r
|
||||
|
||||
def test_limit_task_impact(self, job_host_limit):
|
||||
def test_limit_task_impact(self, job_host_limit, run_computed_fields_right_away):
|
||||
job = job_host_limit(5, 2)
|
||||
job.inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
|
||||
assert job.inventory.total_hosts == 5
|
||||
assert job.task_impact == 2 + 1 # forks becomes constraint
|
||||
|
||||
def test_host_task_impact(self, job_host_limit):
|
||||
def test_host_task_impact(self, job_host_limit, run_computed_fields_right_away):
|
||||
job = job_host_limit(3, 5)
|
||||
job.inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
|
||||
assert job.task_impact == 3 + 1 # hosts becomes constraint
|
||||
|
||||
def test_shard_task_impact(self, slice_job_factory):
|
||||
def test_shard_task_impact(self, slice_job_factory, run_computed_fields_right_away):
|
||||
# factory creates on host per slice
|
||||
workflow_job = slice_job_factory(3, jt_kwargs={'forks': 50}, spawn=True)
|
||||
# arrange the jobs by their number
|
||||
@@ -308,4 +311,5 @@ class TestTaskImpact:
|
||||
len(jobs[0].inventory.get_script_data(slice_number=i + 1, slice_count=3)['all']['hosts'])
|
||||
for i in range(3)
|
||||
] == [2, 1, 1]
|
||||
jobs[0].inventory.refresh_from_db() # FIXME: computed fields operates on reloaded inventory
|
||||
assert [job.task_impact for job in jobs] == [3, 2, 2]
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from datetime import timedelta
|
||||
|
||||
from awx.main.scheduler import TaskManager
|
||||
from awx.main.scheduler.dependency_graph import DependencyGraph
|
||||
from awx.main.utils import encrypt_field
|
||||
from awx.main.models import WorkflowJobTemplate, JobTemplate
|
||||
|
||||
@@ -326,3 +327,29 @@ def test_shared_dependencies_launch(default_instance_group, job_template_factory
|
||||
iu = [x for x in ii.inventory_updates.all()]
|
||||
assert len(pu) == 1
|
||||
assert len(iu) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_not_blocking_project_update(default_instance_group, job_template_factory):
|
||||
objects = job_template_factory('jt', organization='org1', project='proj',
|
||||
inventory='inv', credential='cred',
|
||||
jobs=["job"])
|
||||
job = objects.jobs["job"]
|
||||
job.instance_group = default_instance_group
|
||||
job.status = "running"
|
||||
job.save()
|
||||
|
||||
with mock.patch("awx.main.scheduler.TaskManager.start_task"):
|
||||
task_manager = TaskManager()
|
||||
task_manager._schedule()
|
||||
|
||||
proj = objects.project
|
||||
project_update = proj.create_project_update()
|
||||
project_update.instance_group = default_instance_group
|
||||
project_update.status = "pending"
|
||||
project_update.save()
|
||||
assert not task_manager.is_job_blocked(project_update)
|
||||
|
||||
dependency_graph = DependencyGraph(None)
|
||||
dependency_graph.add_job(job)
|
||||
assert not dependency_graph.is_job_blocked(project_update)
|
||||
|
||||
@@ -54,7 +54,8 @@ INI_TEST_VARS = {
|
||||
},
|
||||
'azure_rm': {
|
||||
'use_private_ip': True,
|
||||
'resource_groups': 'foo_resources,bar_resources'
|
||||
'resource_groups': 'foo_resources,bar_resources',
|
||||
'tags': 'Creator:jmarshall, peanutbutter:jelly'
|
||||
},
|
||||
'satellite6': {
|
||||
'satellite6_group_patterns': 'foo_group_patterns',
|
||||
|
||||
@@ -2,52 +2,10 @@ import pytest
|
||||
from unittest import mock
|
||||
|
||||
from awx.main.migrations import _inventory_source as invsrc
|
||||
from awx.main.models import InventorySource
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inv_src_manual_removal(inventory_source):
|
||||
inventory_source.source = ''
|
||||
inventory_source.save()
|
||||
|
||||
assert InventorySource.objects.filter(pk=inventory_source.pk).exists()
|
||||
invsrc.remove_manual_inventory_sources(apps, None)
|
||||
assert not InventorySource.objects.filter(pk=inventory_source.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rax_inv_src_removal(inventory_source):
|
||||
inventory_source.source = 'rax'
|
||||
inventory_source.save()
|
||||
|
||||
assert InventorySource.objects.filter(pk=inventory_source.pk).exists()
|
||||
invsrc.remove_rax_inventory_sources(apps, None)
|
||||
assert not InventorySource.objects.filter(pk=inventory_source.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inv_src_rename(inventory_source_factory):
|
||||
inv_src01 = inventory_source_factory('t1')
|
||||
|
||||
invsrc.rename_inventory_sources(apps, None)
|
||||
|
||||
inv_src01.refresh_from_db()
|
||||
# inv-is-t1 is generated in the inventory_source_factory
|
||||
assert inv_src01.name == 't1 - inv-is-t1 - 0'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_azure_inv_src_removal(inventory_source):
|
||||
inventory_source.source = 'azure'
|
||||
inventory_source.save()
|
||||
|
||||
assert InventorySource.objects.filter(pk=inventory_source.pk).exists()
|
||||
invsrc.remove_azure_inventory_sources(apps, None)
|
||||
assert not InventorySource.objects.filter(pk=inventory_source.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('vars,id_var,result', [
|
||||
({'foo': {'bar': '1234'}}, 'foo.bar', '1234'),
|
||||
({'cat': 'meow'}, 'cat', 'meow'),
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c) 2017 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
import pytest
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from awx.main.models.base import PERM_INVENTORY_SCAN, PERM_INVENTORY_DEPLOY
|
||||
from awx.main.models import (
|
||||
JobTemplate,
|
||||
Project,
|
||||
Inventory,
|
||||
Organization,
|
||||
)
|
||||
|
||||
from awx.main.migrations._scan_jobs import _migrate_scan_job_templates
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def organizations():
|
||||
return [Organization.objects.create(name=u"org-\xe9-{}".format(x)) for x in range(3)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inventories(organizations):
|
||||
return [Inventory.objects.create(name=u"inv-\xe9-{}".format(x),
|
||||
organization=organizations[x]) for x in range(3)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_templates_scan(inventories):
|
||||
return [JobTemplate.objects.create(name=u"jt-\xe9-scan-{}".format(x),
|
||||
job_type=PERM_INVENTORY_SCAN,
|
||||
inventory=inventories[x]) for x in range(3)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_templates_deploy(inventories):
|
||||
return [JobTemplate.objects.create(name=u"jt-\xe9-deploy-{}".format(x),
|
||||
job_type=PERM_INVENTORY_DEPLOY,
|
||||
inventory=inventories[x]) for x in range(3)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_custom(organizations):
|
||||
return Project.objects.create(name=u"proj-\xe9-scan_custom",
|
||||
scm_url='https://giggity.com',
|
||||
organization=organizations[0])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_templates_custom_scan_project(project_custom):
|
||||
return [JobTemplate.objects.create(name=u"jt-\xe9-scan-custom-{}".format(x),
|
||||
project=project_custom,
|
||||
job_type=PERM_INVENTORY_SCAN) for x in range(3)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_template_scan_no_org():
|
||||
return JobTemplate.objects.create(name=u"jt-\xe9-scan-no-org",
|
||||
job_type=PERM_INVENTORY_SCAN)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scan_jobs_migration(job_templates_scan, job_templates_deploy, job_templates_custom_scan_project, project_custom, job_template_scan_no_org):
|
||||
_migrate_scan_job_templates(apps)
|
||||
|
||||
# Ensure there are no scan job templates after the migration
|
||||
assert 0 == JobTemplate.objects.filter(job_type=PERM_INVENTORY_SCAN).count()
|
||||
|
||||
# Ensure special No Organization proj created
|
||||
# And No Organization project is associated with correct jt
|
||||
proj = Project.objects.get(name="Tower Fact Scan - No Organization")
|
||||
assert proj.id == JobTemplate.objects.get(id=job_template_scan_no_org.id).project.id
|
||||
|
||||
# Ensure per-org projects were created
|
||||
projs = Project.objects.filter(name__startswith="Tower Fact Scan")
|
||||
assert projs.count() == 4
|
||||
|
||||
# Ensure scan job templates with Tower project are migrated
|
||||
for i, jt_old in enumerate(job_templates_scan):
|
||||
jt = JobTemplate.objects.get(id=jt_old.id)
|
||||
assert PERM_INVENTORY_DEPLOY == jt.job_type
|
||||
assert jt.use_fact_cache is True
|
||||
assert projs[i] == jt.project
|
||||
|
||||
# Ensure scan job templates with custom projects are migrated
|
||||
for jt_old in job_templates_custom_scan_project:
|
||||
jt = JobTemplate.objects.get(id=jt_old.id)
|
||||
assert PERM_INVENTORY_DEPLOY == jt.job_type
|
||||
assert jt.use_fact_cache is True
|
||||
assert project_custom == jt.project
|
||||
|
||||
# Ensure other job template aren't touched
|
||||
for jt_old in job_templates_deploy:
|
||||
jt = JobTemplate.objects.get(id=jt_old.id)
|
||||
assert PERM_INVENTORY_DEPLOY == jt.job_type
|
||||
assert jt.project is None
|
||||
|
||||
@@ -12,7 +12,9 @@ from awx.main.models import SystemJobTemplate
|
||||
{"days": 13435},
|
||||
])
|
||||
def test_valid__clean_extra_data_system_jobs(extra_data):
|
||||
accepted, rejected, errors = SystemJobTemplate().accept_or_ignore_variables(extra_data)
|
||||
accepted, rejected, errors = SystemJobTemplate(
|
||||
job_type='cleanup_jobs'
|
||||
).accept_or_ignore_variables(extra_data)
|
||||
assert not rejected
|
||||
assert not errors
|
||||
|
||||
@@ -32,12 +34,14 @@ def test_valid__clean_extra_data_system_jobs(extra_data):
|
||||
{"days": "foobar"},
|
||||
])
|
||||
def test_invalid__extra_data_system_jobs(extra_data):
|
||||
accepted, rejected, errors = SystemJobTemplate().accept_or_ignore_variables(extra_data)
|
||||
accepted, rejected, errors = SystemJobTemplate(
|
||||
job_type='cleanup_jobs'
|
||||
).accept_or_ignore_variables(extra_data)
|
||||
assert str(errors['extra_vars'][0]) == u'days must be a positive integer.'
|
||||
|
||||
|
||||
def test_unallowed_system_job_data():
|
||||
sjt = SystemJobTemplate()
|
||||
sjt = SystemJobTemplate(job_type='cleanup_jobs')
|
||||
accepted, ignored, errors = sjt.accept_or_ignore_variables({
|
||||
'days': 34,
|
||||
'foobar': 'baz'
|
||||
@@ -54,7 +58,7 @@ def test_reject_other_prommpts():
|
||||
|
||||
|
||||
def test_reject_some_accept_some():
|
||||
sjt = SystemJobTemplate()
|
||||
sjt = SystemJobTemplate(job_type='cleanup_jobs')
|
||||
accepted, ignored, errors = sjt._accept_or_ignore_job_kwargs(limit="", extra_vars={
|
||||
'days': 34,
|
||||
'foobar': 'baz'
|
||||
|
||||
@@ -36,11 +36,14 @@ from django.utils.encoding import smart_str
|
||||
from django.utils.text import slugify
|
||||
from django.apps import apps
|
||||
|
||||
# AWX
|
||||
from awx.conf.license import get_license
|
||||
|
||||
logger = logging.getLogger('awx.main.utils')
|
||||
|
||||
__all__ = [
|
||||
'get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelcase', 'memoize',
|
||||
'memoize_delete', 'get_ansible_version', 'get_ssh_version', 'get_licenser',
|
||||
'memoize_delete', 'get_ansible_version', 'get_ssh_version', 'get_licenser', 'get_awx_http_client_headers',
|
||||
'get_awx_version', 'update_scm_url', 'get_type_for_model', 'get_model_for_type',
|
||||
'copy_model_by_class', 'region_sorting', 'copy_m2m_relationships',
|
||||
'prefetch_page_capabilities', 'to_python_boolean', 'ignore_inventory_computed_fields',
|
||||
@@ -212,6 +215,19 @@ def get_awx_version():
|
||||
return __version__
|
||||
|
||||
|
||||
def get_awx_http_client_headers():
|
||||
license = get_license(show_key=False).get('license_type', 'UNLICENSED')
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': '{} {} ({})'.format(
|
||||
'AWX' if license == 'open' else 'Red Hat Ansible Tower',
|
||||
get_awx_version(),
|
||||
license
|
||||
)
|
||||
}
|
||||
return headers
|
||||
|
||||
|
||||
class StubLicense(object):
|
||||
|
||||
features = {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
# Copyright (c) 2017 Ansible by Red Hat
|
||||
# All Rights Reserved.
|
||||
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.db import connection
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.utils.db')
|
||||
|
||||
|
||||
def get_all_field_names(model):
|
||||
# Implements compatibility with _meta.get_all_field_names
|
||||
@@ -14,3 +22,21 @@ def get_all_field_names(model):
|
||||
# GenericForeignKey from the results.
|
||||
if not (field.many_to_one and field.related_model is None)
|
||||
)))
|
||||
|
||||
|
||||
def migration_in_progress_check_or_relase():
|
||||
'''A memcache flag is raised (set to True) to inform cluster
|
||||
that a migration is ongoing see main.apps.MainConfig.ready
|
||||
if the flag is True then the flag is removed on this instance if
|
||||
models-db consistency is observed
|
||||
effective value of migration flag is returned
|
||||
'''
|
||||
migration_in_progress = cache.get('migration_in_progress', False)
|
||||
if migration_in_progress:
|
||||
executor = MigrationExecutor(connection)
|
||||
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
|
||||
if not bool(plan):
|
||||
logger.info('Detected that migration finished, migration flag taken down.')
|
||||
cache.delete('migration_in_progress')
|
||||
migration_in_progress = False
|
||||
return migration_in_progress
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
@@ -35,7 +37,7 @@ class Fernet256(Fernet):
|
||||
self._backend = backend
|
||||
|
||||
|
||||
def get_encryption_key(field_name, pk=None):
|
||||
def get_encryption_key(field_name, pk=None, secret_key=None):
|
||||
'''
|
||||
Generate key for encrypted password based on field name,
|
||||
``settings.SECRET_KEY``, and instance pk (if available).
|
||||
@@ -46,19 +48,58 @@ def get_encryption_key(field_name, pk=None):
|
||||
'''
|
||||
from django.conf import settings
|
||||
h = hashlib.sha512()
|
||||
h.update(smart_bytes(settings.SECRET_KEY))
|
||||
h.update(smart_bytes(secret_key or settings.SECRET_KEY))
|
||||
if pk is not None:
|
||||
h.update(smart_bytes(str(pk)))
|
||||
h.update(smart_bytes(field_name))
|
||||
return base64.urlsafe_b64encode(h.digest())
|
||||
|
||||
|
||||
def encrypt_value(value, pk=None):
|
||||
def encrypt_value(value, pk=None, secret_key=None):
|
||||
#
|
||||
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
||||
#
|
||||
# !!! BEFORE USING THIS FUNCTION PLEASE READ encrypt_field !!!
|
||||
#
|
||||
TransientField = namedtuple('TransientField', ['pk', 'value'])
|
||||
return encrypt_field(TransientField(pk=pk, value=value), 'value')
|
||||
return encrypt_field(TransientField(pk=pk, value=value), 'value', secret_key=secret_key)
|
||||
|
||||
|
||||
def encrypt_field(instance, field_name, ask=False, subfield=None):
|
||||
def encrypt_field(instance, field_name, ask=False, subfield=None, secret_key=None):
|
||||
#
|
||||
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
||||
#
|
||||
# !!! PLEASE READ BEFORE USING THIS FUNCTION ANYWHERE !!!
|
||||
#
|
||||
# You should know that this function is used in various places throughout
|
||||
# AWX for symmetric encryption - generally it's used to encrypt sensitive
|
||||
# values that we store in the AWX database (such as SSH private keys for
|
||||
# credentials).
|
||||
#
|
||||
# If you're reading this function's code because you're thinking about
|
||||
# using it to encrypt *something new*, please remember that AWX has
|
||||
# official support for *regenerating* the SECRET_KEY (on which the
|
||||
# symmetric key is based):
|
||||
#
|
||||
# $ awx-manage regenerate_secret_key
|
||||
# $ setup.sh -k
|
||||
#
|
||||
# ...so you'll need to *also* add code to support the
|
||||
# migration/re-encryption of these values (the code in question lives in
|
||||
# `awx.main.management.commands.regenerate_secret_key`):
|
||||
#
|
||||
# For example, if you find that you're adding a new database column that is
|
||||
# encrypted, in addition to calling `encrypt_field` in the appropriate
|
||||
# places, you would also need to update the `awx-manage regenerate_secret_key`
|
||||
# so that values are properly migrated when the SECRET_KEY changes.
|
||||
#
|
||||
# This process *generally* involves adding Python code to the
|
||||
# `regenerate_secret_key` command, i.e.,
|
||||
#
|
||||
# 1. Query the database for existing encrypted values on the appropriate object(s)
|
||||
# 2. Decrypting them using the *old* SECRET_KEY
|
||||
# 3. Storing newly encrypted values using the *newly generated* SECRET_KEY
|
||||
#
|
||||
'''
|
||||
Return content of the given instance and field name encrypted.
|
||||
'''
|
||||
@@ -76,7 +117,11 @@ def encrypt_field(instance, field_name, ask=False, subfield=None):
|
||||
value = smart_str(value)
|
||||
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
|
||||
return value
|
||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||
key = get_encryption_key(
|
||||
field_name,
|
||||
getattr(instance, 'pk', None),
|
||||
secret_key=secret_key
|
||||
)
|
||||
f = Fernet256(key)
|
||||
encrypted = f.encrypt(smart_bytes(value))
|
||||
b64data = smart_str(base64.b64encode(encrypted))
|
||||
@@ -99,7 +144,7 @@ def decrypt_value(encryption_key, value):
|
||||
return smart_str(value)
|
||||
|
||||
|
||||
def decrypt_field(instance, field_name, subfield=None):
|
||||
def decrypt_field(instance, field_name, subfield=None, secret_key=None):
|
||||
'''
|
||||
Return content of the given instance and field name decrypted.
|
||||
'''
|
||||
@@ -115,7 +160,11 @@ def decrypt_field(instance, field_name, subfield=None):
|
||||
value = smart_str(value)
|
||||
if not value or not value.startswith('$encrypted$'):
|
||||
return value
|
||||
key = get_encryption_key(field_name, getattr(instance, 'pk', None))
|
||||
key = get_encryption_key(
|
||||
field_name,
|
||||
getattr(instance, 'pk', None),
|
||||
secret_key=secret_key
|
||||
)
|
||||
|
||||
try:
|
||||
return smart_str(decrypt_value(key, value))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import json
|
||||
|
||||
# Django
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@@ -97,3 +97,6 @@ def handle_csp_violation(request):
|
||||
logger = logging.getLogger('awx')
|
||||
logger.error(json.loads(request.body))
|
||||
return HttpResponse(content=None)
|
||||
|
||||
def handle_login_redirect(request):
|
||||
return HttpResponseRedirect("/#/login")
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
src: "{{src}}/artifacts/"
|
||||
dest: "{{src}}/artifacts/"
|
||||
mode: pull
|
||||
delete: yes
|
||||
recursive: yes
|
||||
delete: true
|
||||
recursive: true
|
||||
when: ansible_kubectl_config is not defined
|
||||
|
||||
- name: Copy daemon log from the isolated host
|
||||
@@ -34,9 +34,9 @@
|
||||
src: "{{src}}/artifacts/"
|
||||
dest: "{{src}}/artifacts/"
|
||||
mode: pull
|
||||
delete: yes
|
||||
recursive: yes
|
||||
set_remote_user: no
|
||||
delete: true
|
||||
recursive: true
|
||||
set_remote_user: false
|
||||
rsync_opts:
|
||||
- "--rsh=$RSH"
|
||||
environment:
|
||||
@@ -49,7 +49,7 @@
|
||||
src: "{{src}}/daemon.log"
|
||||
dest: "{{src}}/daemon.log"
|
||||
mode: pull
|
||||
set_remote_user: no
|
||||
set_remote_user: false
|
||||
rsync_opts:
|
||||
- "--rsh=$RSH"
|
||||
environment:
|
||||
|
||||
@@ -12,14 +12,16 @@
|
||||
|
||||
- name: cancel the job
|
||||
command: "ansible-runner stop {{private_data_dir}}"
|
||||
ignore_errors: yes
|
||||
ignore_errors: true
|
||||
|
||||
- name: remove build artifacts
|
||||
file: path="{{item}}" state=absent
|
||||
file:
|
||||
path: '{{item}}'
|
||||
state: absent
|
||||
register: result
|
||||
with_items: "{{cleanup_dirs}}"
|
||||
until: result is succeeded
|
||||
ignore_errors: yes
|
||||
ignore_errors: true
|
||||
retries: 3
|
||||
delay: 5
|
||||
|
||||
|
||||
@@ -1,160 +1,173 @@
|
||||
---
|
||||
# The following variables will be set by the runner of this playbook:
|
||||
# project_path: PROJECTS_DIR/_local_path_
|
||||
# scm_type: git|hg|svn|insights
|
||||
# scm_url: https://server/repo
|
||||
# insights_url: Insights service URL (from configuration)
|
||||
# scm_branch: branch/tag/revision (HEAD if unset)
|
||||
# scm_clean: true/false
|
||||
# scm_delete_on_update: true/false
|
||||
# scm_full_checkout: true (if for a job template run), false (if retrieving revision)
|
||||
# scm_username: username (only for svn/insights)
|
||||
# scm_password: password (only for svn/insights)
|
||||
# scm_accept_hostkey: true/false (only for git, set automatically)
|
||||
# scm_refspec: a refspec to fetch in addition to obtaining version
|
||||
# roles_enabled: Allow us to pull roles from a requirements.yml file
|
||||
# roles_enabled: Value of the global setting to enable roles downloading
|
||||
# collections_enabled: Value of the global setting to enable collections downloading
|
||||
# roles_destination: Path to save roles from galaxy to
|
||||
# collections_destination: Path to save collections from galaxy to
|
||||
# awx_version: Current running version of the awx or tower as a string
|
||||
# awx_license_type: "open" for AWX; else presume Tower
|
||||
|
||||
- hosts: all
|
||||
- hosts: localhost
|
||||
gather_facts: false
|
||||
connection: local
|
||||
name: Update source tree if necessary
|
||||
tasks:
|
||||
|
||||
- name: delete project directory before update
|
||||
file:
|
||||
path: "{{project_path|quote}}"
|
||||
state: absent
|
||||
when: scm_delete_on_update|default('')
|
||||
delegate_to: localhost
|
||||
tags:
|
||||
- delete
|
||||
|
||||
- block:
|
||||
- name: update project using git
|
||||
git:
|
||||
dest: "{{project_path|quote}}"
|
||||
repo: "{{scm_url}}"
|
||||
version: "{{scm_branch|quote}}"
|
||||
refspec: "{{scm_refspec|default(omit)}}"
|
||||
force: "{{scm_clean}}"
|
||||
accept_hostkey: "{{scm_accept_hostkey|default(omit)}}"
|
||||
register: git_result
|
||||
- name: update project using git
|
||||
git:
|
||||
dest: "{{project_path|quote}}"
|
||||
repo: "{{scm_url}}"
|
||||
version: "{{scm_branch|quote}}"
|
||||
refspec: "{{scm_refspec|default(omit)}}"
|
||||
force: "{{scm_clean}}"
|
||||
accept_hostkey: "{{scm_accept_hostkey|default(omit)}}"
|
||||
register: git_result
|
||||
|
||||
- name: Set the git repository version
|
||||
set_fact:
|
||||
scm_version: "{{ git_result['after'] }}"
|
||||
when: "'after' in git_result"
|
||||
when: scm_type == 'git'
|
||||
delegate_to: localhost
|
||||
- name: Set the git repository version
|
||||
set_fact:
|
||||
scm_version: "{{ git_result['after'] }}"
|
||||
when: "'after' in git_result"
|
||||
tags:
|
||||
- update_git
|
||||
|
||||
- block:
|
||||
- name: update project using hg
|
||||
hg:
|
||||
dest: "{{project_path|quote}}"
|
||||
repo: "{{scm_url|quote}}"
|
||||
revision: "{{scm_branch|quote}}"
|
||||
force: "{{scm_clean}}"
|
||||
register: hg_result
|
||||
- name: update project using hg
|
||||
hg:
|
||||
dest: "{{project_path|quote}}"
|
||||
repo: "{{scm_url|quote}}"
|
||||
revision: "{{scm_branch|quote}}"
|
||||
force: "{{scm_clean}}"
|
||||
register: hg_result
|
||||
|
||||
- name: Set the hg repository version
|
||||
set_fact:
|
||||
scm_version: "{{ hg_result['after'] }}"
|
||||
when: "'after' in hg_result"
|
||||
- name: Set the hg repository version
|
||||
set_fact:
|
||||
scm_version: "{{ hg_result['after'] }}"
|
||||
when: "'after' in hg_result"
|
||||
|
||||
- name: parse hg version string properly
|
||||
set_fact:
|
||||
scm_version: "{{scm_version|regex_replace('^([A-Za-z0-9]+).*$', '\\1')}}"
|
||||
when: scm_type == 'hg'
|
||||
delegate_to: localhost
|
||||
- name: parse hg version string properly
|
||||
set_fact:
|
||||
scm_version: "{{scm_version|regex_replace('^([A-Za-z0-9]+).*$', '\\1')}}"
|
||||
tags:
|
||||
- update_hg
|
||||
|
||||
- block:
|
||||
- name: update project using svn
|
||||
subversion:
|
||||
dest: "{{project_path|quote}}"
|
||||
repo: "{{scm_url|quote}}"
|
||||
revision: "{{scm_branch|quote}}"
|
||||
force: "{{scm_clean}}"
|
||||
username: "{{scm_username|default(omit)}}"
|
||||
password: "{{scm_password|default(omit)}}"
|
||||
environment:
|
||||
LC_ALL: 'en_US.UTF-8'
|
||||
register: svn_result
|
||||
- name: update project using svn
|
||||
subversion:
|
||||
dest: "{{project_path|quote}}"
|
||||
repo: "{{scm_url|quote}}"
|
||||
revision: "{{scm_branch|quote}}"
|
||||
force: "{{scm_clean}}"
|
||||
username: "{{scm_username|default(omit)}}"
|
||||
password: "{{scm_password|default(omit)}}"
|
||||
environment:
|
||||
LC_ALL: 'en_US.UTF-8'
|
||||
register: svn_result
|
||||
|
||||
- name: Set the svn repository version
|
||||
set_fact:
|
||||
scm_version: "{{ svn_result['after'] }}"
|
||||
when: "'after' in svn_result"
|
||||
- name: Set the svn repository version
|
||||
set_fact:
|
||||
scm_version: "{{ svn_result['after'] }}"
|
||||
when: "'after' in svn_result"
|
||||
|
||||
- name: parse subversion version string properly
|
||||
set_fact:
|
||||
scm_version: "{{scm_version|regex_replace('^.*Revision: ([0-9]+).*$', '\\1')}}"
|
||||
when: scm_type == 'svn'
|
||||
delegate_to: localhost
|
||||
- name: parse subversion version string properly
|
||||
set_fact:
|
||||
scm_version: "{{scm_version|regex_replace('^.*Revision: ([0-9]+).*$', '\\1')}}"
|
||||
tags:
|
||||
- update_svn
|
||||
|
||||
- block:
|
||||
- name: Ensure the project directory is present
|
||||
file:
|
||||
dest: "{{project_path|quote}}"
|
||||
state: directory
|
||||
- name: Ensure the project directory is present
|
||||
file:
|
||||
dest: "{{project_path|quote}}"
|
||||
state: directory
|
||||
|
||||
- name: Fetch Insights Playbook(s)
|
||||
insights:
|
||||
insights_url: "{{insights_url}}"
|
||||
username: "{{scm_username}}"
|
||||
password: "{{scm_password}}"
|
||||
project_path: "{{project_path}}"
|
||||
awx_license_type: "{{awx_license_type}}"
|
||||
awx_version: "{{awx_version}}"
|
||||
register: results
|
||||
|
||||
- name: Save Insights Version
|
||||
set_fact:
|
||||
scm_version: "{{results.version}}"
|
||||
when: results is defined
|
||||
when: scm_type == 'insights'
|
||||
delegate_to: localhost
|
||||
- name: Fetch Insights Playbook(s)
|
||||
insights:
|
||||
insights_url: "{{insights_url}}"
|
||||
username: "{{scm_username}}"
|
||||
password: "{{scm_password}}"
|
||||
project_path: "{{project_path}}"
|
||||
awx_license_type: "{{awx_license_type}}"
|
||||
awx_version: "{{awx_version}}"
|
||||
register: results
|
||||
|
||||
- name: Save Insights Version
|
||||
set_fact:
|
||||
scm_version: "{{results.version}}"
|
||||
when: results is defined
|
||||
tags:
|
||||
- update_insights
|
||||
|
||||
- name: Repository Version
|
||||
debug: msg="Repository Version {{ scm_version }}"
|
||||
when: scm_version is defined
|
||||
debug:
|
||||
msg: "Repository Version {{ scm_version }}"
|
||||
tags:
|
||||
- update_git
|
||||
- update_hg
|
||||
- update_svn
|
||||
- update_insights
|
||||
|
||||
- hosts: all
|
||||
- hosts: localhost
|
||||
gather_facts: false
|
||||
connection: local
|
||||
name: Install content with ansible-galaxy command if necessary
|
||||
tasks:
|
||||
|
||||
- block:
|
||||
- name: detect requirements.yml
|
||||
stat: path={{project_path|quote}}/roles/requirements.yml
|
||||
register: doesRequirementsExist
|
||||
- name: detect requirements.yml
|
||||
stat:
|
||||
path: '{{project_path|quote}}/roles/requirements.yml'
|
||||
register: doesRequirementsExist
|
||||
|
||||
- name: fetch galaxy roles from requirements.yml
|
||||
command: ansible-galaxy install -r requirements.yml -p {{roles_destination|quote}}{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
|
||||
args:
|
||||
chdir: "{{project_path|quote}}/roles"
|
||||
register: galaxy_result
|
||||
when: doesRequirementsExist.stat.exists
|
||||
changed_when: "'was installed successfully' in galaxy_result.stdout"
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: False
|
||||
- name: fetch galaxy roles from requirements.yml
|
||||
command: ansible-galaxy install -r requirements.yml -p {{roles_destination|quote}}{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
|
||||
args:
|
||||
chdir: "{{project_path|quote}}/roles"
|
||||
register: galaxy_result
|
||||
when: doesRequirementsExist.stat.exists
|
||||
changed_when: "'was installed successfully' in galaxy_result.stdout"
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: false
|
||||
|
||||
when: roles_enabled|bool
|
||||
delegate_to: localhost
|
||||
tags:
|
||||
- install_roles
|
||||
|
||||
- block:
|
||||
- name: detect collections/requirements.yml
|
||||
stat: path={{project_path|quote}}/collections/requirements.yml
|
||||
register: doesCollectionRequirementsExist
|
||||
- name: detect collections/requirements.yml
|
||||
stat:
|
||||
path: '{{project_path|quote}}/collections/requirements.yml'
|
||||
register: doesCollectionRequirementsExist
|
||||
|
||||
- name: fetch galaxy collections from collections/requirements.yml
|
||||
command: ansible-galaxy collection install -r requirements.yml -p {{collections_destination|quote}}{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
|
||||
args:
|
||||
chdir: "{{project_path|quote}}/collections"
|
||||
register: galaxy_collection_result
|
||||
when: doesCollectionRequirementsExist.stat.exists
|
||||
changed_when: "'Installing ' in galaxy_collection_result.stdout"
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: False
|
||||
ANSIBLE_COLLECTIONS_PATHS: "{{ collections_destination }}"
|
||||
- name: fetch galaxy collections from collections/requirements.yml
|
||||
command: ansible-galaxy collection install -r requirements.yml -p {{collections_destination|quote}}{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
|
||||
args:
|
||||
chdir: "{{project_path|quote}}/collections"
|
||||
register: galaxy_collection_result
|
||||
when: doesCollectionRequirementsExist.stat.exists
|
||||
changed_when: "'Installing ' in galaxy_collection_result.stdout"
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: false
|
||||
ANSIBLE_COLLECTIONS_PATHS: "{{ collections_destination }}"
|
||||
|
||||
when: collections_enabled|bool
|
||||
delegate_to: localhost
|
||||
when:
|
||||
- "ansible_version.full is version_compare('2.8', '>=')"
|
||||
- collections_enabled|bool
|
||||
tags:
|
||||
- install_collections
|
||||
|
||||
@@ -13,17 +13,17 @@
|
||||
tasks:
|
||||
- name: synchronize job environment with isolated host
|
||||
synchronize:
|
||||
copy_links: yes
|
||||
copy_links: true
|
||||
src: "{{ src }}"
|
||||
dest: "{{ dest }}"
|
||||
when: ansible_kubectl_config is not defined
|
||||
|
||||
- name: synchronize job environment with remote job container
|
||||
synchronize:
|
||||
copy_links: yes
|
||||
copy_links: true
|
||||
src: "{{ src }}"
|
||||
dest: "{{ dest }}"
|
||||
set_remote_user: no
|
||||
set_remote_user: false
|
||||
rsync_opts:
|
||||
- "--rsh=$RSH"
|
||||
environment:
|
||||
@@ -51,4 +51,4 @@
|
||||
content: "{{secret}}"
|
||||
path: "{{src}}/env/ssh_key"
|
||||
when: key.stat.exists
|
||||
no_log: True
|
||||
no_log: true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
- hosts: all
|
||||
vars:
|
||||
scan_use_checksum: false
|
||||
@@ -33,4 +34,3 @@
|
||||
get_checksum: '{{ scan_use_checksum }}'
|
||||
recursive: '{{ scan_use_recursive }}'
|
||||
when: scan_file_paths is defined and ansible_os_family == "Windows"
|
||||
|
||||
|
||||
@@ -123,6 +123,12 @@ user = foreman
|
||||
password = secret
|
||||
ssl_verify = True
|
||||
|
||||
# Foreman 1.24 introduces a new reports API to improve performance of the inventory script.
|
||||
# Note: This requires foreman_ansible plugin installed.
|
||||
# Set to False if you want to use the old API. Defaults to True.
|
||||
|
||||
use_reports_api = True
|
||||
|
||||
# Retrieve only hosts from the organization "Web Engineering".
|
||||
# host_filters = organization="Web Engineering"
|
||||
|
||||
@@ -130,15 +136,42 @@ ssl_verify = True
|
||||
# also in the host collection "Apache Servers".
|
||||
# host_filters = organization="Web Engineering" and host_collection="Apache Servers"
|
||||
|
||||
|
||||
# Foreman Inventory report related configuration options.
|
||||
# Configs that default to True :
|
||||
# want_organization , want_location, want_ipv4, want_host_group, want_subnet, want_smart_proxies, want_facts
|
||||
# Configs that default to False :
|
||||
# want_ipv6, want_subnet_v6, want_content_facet_attributes, want_host_params
|
||||
|
||||
[report]
|
||||
want_organization = True
|
||||
want_location = True
|
||||
want_ipv4 = True
|
||||
want_ipv6 = False
|
||||
want_host_group = True
|
||||
want_subnet = True
|
||||
want_subnet_v6 = False
|
||||
want_smart_proxies = True
|
||||
want_content_facet_attributes = False
|
||||
want_host_params = False
|
||||
|
||||
# use this config to determine if facts are to be fetched in the report and stored on the hosts.
|
||||
# want_facts = False
|
||||
|
||||
# Upon receiving a request to return inventory report, Foreman schedules a report generation job.
|
||||
# The script then polls the report_data endpoint repeatedly to check if the job is complete and retrieves data
|
||||
# poll_interval allows to define the polling interval between 2 calls to the report_data endpoint while polling.
|
||||
# Defaults to 10 seconds
|
||||
|
||||
poll_interval = 10
|
||||
|
||||
[ansible]
|
||||
group_patterns = ["{app}-{tier}-{color}",
|
||||
"{app}-{color}",
|
||||
"{app}",
|
||||
"{tier}"]
|
||||
group_prefix = foreman_
|
||||
|
||||
# Whether to fetch facts from Foreman and store them on the host
|
||||
want_facts = True
|
||||
group_prefix = foreman_
|
||||
|
||||
# Whether to create Ansible groups for host collections. Only tested
|
||||
# with Katello (Red Hat Satellite). Disabled by default to not break
|
||||
|
||||
@@ -34,7 +34,7 @@ import copy
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from time import time
|
||||
from time import time, sleep
|
||||
from collections import defaultdict
|
||||
from distutils.version import LooseVersion, StrictVersion
|
||||
|
||||
@@ -92,6 +92,72 @@ class ForemanInventory(object):
|
||||
print("Error parsing configuration: %s" % e, file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Inventory Report Related
|
||||
try:
|
||||
self.foreman_use_reports_api = config.getboolean('foreman', 'use_reports_api')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.foreman_use_reports_api = True
|
||||
|
||||
try:
|
||||
self.want_organization = config.getboolean('report', 'want_organization')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_organization = True
|
||||
|
||||
try:
|
||||
self.want_location = config.getboolean('report', 'want_location')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_location = True
|
||||
|
||||
try:
|
||||
self.want_IPv4 = config.getboolean('report', 'want_ipv4')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_IPv4 = True
|
||||
|
||||
try:
|
||||
self.want_IPv6 = config.getboolean('report', 'want_ipv6')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_IPv6 = False
|
||||
|
||||
try:
|
||||
self.want_host_group = config.getboolean('report', 'want_host_group')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_host_group = True
|
||||
|
||||
try:
|
||||
self.want_host_params = config.getboolean('report', 'want_host_params')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_host_params = False
|
||||
|
||||
try:
|
||||
self.want_subnet = config.getboolean('report', 'want_subnet')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_subnet = True
|
||||
|
||||
try:
|
||||
self.want_subnet_v6 = config.getboolean('report', 'want_subnet_v6')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_subnet_v6 = False
|
||||
|
||||
try:
|
||||
self.want_smart_proxies = config.getboolean('report', 'want_smart_proxies')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_smart_proxies = True
|
||||
|
||||
try:
|
||||
self.want_content_facet_attributes = config.getboolean('report', 'want_content_facet_attributes')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_content_facet_attributes = False
|
||||
|
||||
try:
|
||||
self.report_want_facts = config.getboolean('report', 'want_facts')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.report_want_facts = True
|
||||
|
||||
try:
|
||||
self.poll_interval = config.getint('report', 'poll_interval')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.poll_interval = 10
|
||||
|
||||
# Ansible related
|
||||
try:
|
||||
group_patterns = config.get('ansible', 'group_patterns')
|
||||
@@ -110,6 +176,8 @@ class ForemanInventory(object):
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
self.want_facts = True
|
||||
|
||||
self.want_facts = self.want_facts and self.report_want_facts
|
||||
|
||||
try:
|
||||
self.want_hostcollections = config.getboolean('ansible', 'want_hostcollections')
|
||||
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
||||
@@ -203,6 +271,52 @@ class ForemanInventory(object):
|
||||
break
|
||||
return results
|
||||
|
||||
def _use_inventory_report(self):
|
||||
if not self.foreman_use_reports_api:
|
||||
return False
|
||||
status_url = "%s/api/v2/status" % self.foreman_url
|
||||
result = self._get_json(status_url)
|
||||
foreman_version = (LooseVersion(result.get('version')) >= LooseVersion('1.24.0'))
|
||||
return foreman_version
|
||||
|
||||
def _fetch_params(self):
|
||||
options, params = ("no", "yes"), dict()
|
||||
params["Organization"] = options[self.want_organization]
|
||||
params["Location"] = options[self.want_location]
|
||||
params["IPv4"] = options[self.want_IPv4]
|
||||
params["IPv6"] = options[self.want_IPv6]
|
||||
params["Facts"] = options[self.want_facts]
|
||||
params["Host Group"] = options[self.want_host_group]
|
||||
params["Host Collections"] = options[self.want_hostcollections]
|
||||
params["Subnet"] = options[self.want_subnet]
|
||||
params["Subnet v6"] = options[self.want_subnet_v6]
|
||||
params["Smart Proxies"] = options[self.want_smart_proxies]
|
||||
params["Content Attributes"] = options[self.want_content_facet_attributes]
|
||||
params["Host Parameters"] = options[self.want_host_params]
|
||||
if self.host_filters:
|
||||
params["Hosts"] = self.host_filters
|
||||
return params
|
||||
|
||||
def _post_request(self):
|
||||
url = "%s/ansible/api/v2/ansible_inventories/schedule" % self.foreman_url
|
||||
session = self._get_session()
|
||||
params = {'input_values': self._fetch_params()}
|
||||
ret = session.post(url, json=params)
|
||||
if not ret:
|
||||
raise Exception("Error scheduling inventory report on foreman. Please check foreman logs!")
|
||||
url = "{0}/{1}".format(self.foreman_url, ret.json().get('data_url'))
|
||||
response = session.get(url)
|
||||
while response:
|
||||
if response.status_code != 204:
|
||||
break
|
||||
else:
|
||||
sleep(self.poll_interval)
|
||||
response = session.get(url)
|
||||
if not response:
|
||||
raise Exception("Error receiving inventory report from foreman. Please check foreman logs!")
|
||||
else:
|
||||
return response.json()
|
||||
|
||||
def _get_hosts(self):
|
||||
url = "%s/api/v2/hosts" % self.foreman_url
|
||||
|
||||
@@ -276,6 +390,97 @@ class ForemanInventory(object):
|
||||
|
||||
def update_cache(self, scan_only_new_hosts=False):
|
||||
"""Make calls to foreman and save the output in a cache"""
|
||||
use_inventory_report = self._use_inventory_report()
|
||||
if use_inventory_report:
|
||||
self._update_cache_inventory(scan_only_new_hosts)
|
||||
else:
|
||||
self._update_cache_host_api(scan_only_new_hosts)
|
||||
|
||||
def _update_cache_inventory(self, scan_only_new_hosts):
|
||||
self.groups = dict()
|
||||
self.hosts = dict()
|
||||
try:
|
||||
inventory_report_response = self._post_request()
|
||||
except Exception:
|
||||
self._update_cache_host_api(scan_only_new_hosts)
|
||||
return
|
||||
host_data = json.loads(inventory_report_response)
|
||||
for host in host_data:
|
||||
if not(host) or (host["name"] in self.cache.keys() and scan_only_new_hosts):
|
||||
continue
|
||||
dns_name = host['name']
|
||||
|
||||
host_params = host.pop('host_parameters', {})
|
||||
fact_list = host.pop('facts', {})
|
||||
content_facet_attributes = host.get('content_attributes', {}) or {}
|
||||
|
||||
# Create ansible groups for hostgroup
|
||||
group = 'host_group'
|
||||
val = host.get(group)
|
||||
if val:
|
||||
safe_key = self.to_safe('%s%s_%s' % (
|
||||
to_text(self.group_prefix),
|
||||
group,
|
||||
to_text(val).lower()
|
||||
))
|
||||
self.inventory[safe_key].append(dns_name)
|
||||
|
||||
# Create ansible groups for environment, location and organization
|
||||
for group in ['environment', 'location', 'organization']:
|
||||
val = host.get('%s' % group)
|
||||
if val:
|
||||
safe_key = self.to_safe('%s%s_%s' % (
|
||||
to_text(self.group_prefix),
|
||||
group,
|
||||
to_text(val).lower()
|
||||
))
|
||||
self.inventory[safe_key].append(dns_name)
|
||||
|
||||
for group in ['lifecycle_environment', 'content_view']:
|
||||
val = content_facet_attributes.get('%s_name' % group)
|
||||
if val:
|
||||
safe_key = self.to_safe('%s%s_%s' % (
|
||||
to_text(self.group_prefix),
|
||||
group,
|
||||
to_text(val).lower()
|
||||
))
|
||||
self.inventory[safe_key].append(dns_name)
|
||||
|
||||
params = host_params
|
||||
|
||||
# Ansible groups by parameters in host groups and Foreman host
|
||||
# attributes.
|
||||
groupby = dict()
|
||||
for k, v in params.items():
|
||||
groupby[k] = self.to_safe(to_text(v))
|
||||
|
||||
# The name of the ansible groups is given by group_patterns:
|
||||
for pattern in self.group_patterns:
|
||||
try:
|
||||
key = pattern.format(**groupby)
|
||||
self.inventory[key].append(dns_name)
|
||||
except KeyError:
|
||||
pass # Host not part of this group
|
||||
|
||||
if self.want_hostcollections:
|
||||
hostcollections = host.get('host_collections')
|
||||
|
||||
if hostcollections:
|
||||
# Create Ansible groups for host collections
|
||||
for hostcollection in hostcollections:
|
||||
safe_key = self.to_safe('%shostcollection_%s' % (self.group_prefix, hostcollection.lower()))
|
||||
self.inventory[safe_key].append(dns_name)
|
||||
|
||||
self.hostcollections[dns_name] = hostcollections
|
||||
|
||||
self.cache[dns_name] = host
|
||||
self.params[dns_name] = params
|
||||
self.facts[dns_name] = fact_list
|
||||
self.inventory['all'].append(dns_name)
|
||||
self._write_cache()
|
||||
|
||||
def _update_cache_host_api(self, scan_only_new_hosts):
|
||||
"""Make calls to foreman and save the output in a cache"""
|
||||
|
||||
self.groups = dict()
|
||||
self.hosts = dict()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
---
|
||||
clouds:
|
||||
vexxhost:
|
||||
profile: vexxhost
|
||||
@@ -19,6 +20,6 @@ clouds:
|
||||
password: stack
|
||||
project_name: stack
|
||||
ansible:
|
||||
use_hostnames: True
|
||||
expand_hostvars: False
|
||||
fail_on_errors: True
|
||||
use_hostnames: true
|
||||
expand_hostvars: false
|
||||
fail_on_errors: true
|
||||
|
||||
@@ -373,6 +373,10 @@ TACACSPLUS_AUTH_PROTOCOL = 'ascii'
|
||||
# Note: This setting may be overridden by database settings.
|
||||
AUTH_BASIC_ENABLED = True
|
||||
|
||||
# If set, specifies a URL that unauthenticated users will be redirected to
|
||||
# when trying to access a UI page that requries authentication.
|
||||
LOGIN_REDIRECT_OVERRIDE = None
|
||||
|
||||
# If set, serve only minified JS for UI.
|
||||
USE_MINIFIED_JS = False
|
||||
|
||||
@@ -605,6 +609,9 @@ PUBLIC_GALAXY_SERVER = {
|
||||
'url': 'https://galaxy.ansible.com'
|
||||
}
|
||||
|
||||
# Applies to any galaxy server
|
||||
GALAXY_IGNORE_CERTS = False
|
||||
|
||||
# List of dicts of fallback (additional) Galaxy servers. If configured, these
|
||||
# will be higher precedence than public Galaxy, but lower than primary Galaxy.
|
||||
# Available options: 'id', 'url', 'username', 'password', 'token', 'auth_url'
|
||||
|
||||
@@ -52,6 +52,9 @@ COLOR_LOGS = True
|
||||
# Pipe management playbook output to console
|
||||
LOGGING['loggers']['awx.isolated.manager.playbooks']['propagate'] = True # noqa
|
||||
|
||||
# celery is annoyingly loud when docker containers start
|
||||
LOGGING['loggers'].pop('celery', None) # noqa
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
mimetypes.add_type("image/svg+xml", ".svg", True)
|
||||
|
||||
@@ -156,7 +156,13 @@ function AddEditCredentialsController (
|
||||
if (credential.get('credential_type') === credentialType.get('id')) {
|
||||
vm.inputSources.items = credential.get('related.input_sources.results');
|
||||
}
|
||||
vm.isTestable = (isEditable && credentialType.get('kind') === 'external');
|
||||
|
||||
if (mode === 'add') {
|
||||
vm.isTestable = (models.me.get('is_superuser') && credentialType.get('kind') === 'external');
|
||||
} else {
|
||||
vm.isTestable = (isEditable && credentialType.get('kind') === 'external');
|
||||
}
|
||||
|
||||
vm.getSubmitData = getSubmitData;
|
||||
vm.checkForm = check;
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ function getStatusDetails (jobStatus) {
|
||||
value = choices[unmapped];
|
||||
}
|
||||
|
||||
return { label, icon, value };
|
||||
return { unmapped, label, icon, value };
|
||||
}
|
||||
|
||||
function getStartDetails (started) {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
class="List-actionButton List-actionButton--delete"
|
||||
data-placement="top"
|
||||
ng-click="vm.cancelJob()"
|
||||
ng-show="vm.status.value === 'Pending' ||
|
||||
vm.status.value === 'Waiting' ||
|
||||
vm.status.value === 'Running'"
|
||||
ng-show="vm.status.unmapped === 'pending' ||
|
||||
vm.status.unmapped === 'waiting' ||
|
||||
vm.status.unmapped === 'running'"
|
||||
aw-tool-tip="{{:: vm.strings.get('tooltips.CANCEL') }}"
|
||||
data-original-title=""
|
||||
title="">
|
||||
@@ -27,11 +27,11 @@
|
||||
data-placement="top"
|
||||
ng-click="vm.deleteJob()"
|
||||
ng-show="vm.canDelete && (
|
||||
vm.status.value === 'New' ||
|
||||
vm.status.value === 'Successful' ||
|
||||
vm.status.value === 'Failed' ||
|
||||
vm.status.value === 'Error' ||
|
||||
vm.status.value === 'Canceled')"
|
||||
vm.status.unmapped === 'new' ||
|
||||
vm.status.unmapped === 'successful' ||
|
||||
vm.status.unmapped === 'failed' ||
|
||||
vm.status.unmapped === 'error' ||
|
||||
vm.status.unmapped === 'canceled')"
|
||||
aw-tool-tip="{{:: vm.strings.get('tooltips.DELETE') }}"
|
||||
data-original-title=""
|
||||
title="">
|
||||
|
||||
@@ -21,12 +21,6 @@
|
||||
src: url(/static/assets/OpenSans-Bold.ttf);
|
||||
}
|
||||
|
||||
/* Bootstrap fix that's causing a right margin to appear
|
||||
whenver a modal is opened */
|
||||
body.modal-open {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Helper Classes */
|
||||
.pad-right-sm { padding-right: 10px; }
|
||||
.pad-left-md { padding-left: 30px; }
|
||||
|
||||
@@ -4,6 +4,13 @@ body {
|
||||
color: @default-data-txt;
|
||||
background-color: @default-secondary-bg;
|
||||
font-size: 0.88rem;
|
||||
/*
|
||||
* Bootstrap fix that's causing a right margin and padding
|
||||
* to appear whenever a modal is opened
|
||||
* https://github.com/twbs/bootstrap/issues/27071
|
||||
*/
|
||||
margin-right: 0px !important;
|
||||
padding-right: 0px !important;
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
|
||||
@@ -3,6 +3,8 @@ global.$AnsibleConfig = null;
|
||||
// Provided via Webpack DefinePlugin in webpack.config.js
|
||||
global.$ENV = {};
|
||||
|
||||
global.$ConfigResponse = {};
|
||||
|
||||
var urlPrefix;
|
||||
|
||||
if ($basePath) {
|
||||
@@ -383,7 +385,11 @@ angular
|
||||
var stime = timestammp[lastUser.id].time,
|
||||
now = new Date().getTime();
|
||||
if ((stime - now) <= 0) {
|
||||
$location.path('/login');
|
||||
if (global.$AnsibleConfig.login_redirect_override) {
|
||||
window.location.replace(global.$AnsibleConfig.login_redirect_override);
|
||||
} else {
|
||||
$location.path('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
// If browser refresh, set the user_is_superuser value
|
||||
|
||||
@@ -15,7 +15,9 @@ function bootstrap (callback) {
|
||||
angular.module('I18N').constant('LOCALE', locale);
|
||||
}
|
||||
|
||||
angular.element(document).ready(() => callback());
|
||||
fetchConfig(() => {
|
||||
angular.element(document).ready(() => callback());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,6 +51,25 @@ function fetchLocaleStrings (callback) {
|
||||
request.fail(() => callback({ code: DEFAULT_LOCALE }));
|
||||
}
|
||||
|
||||
function fetchConfig (callback) {
|
||||
const request = $.ajax('/api/');
|
||||
|
||||
request.done(res => {
|
||||
global.$ConfigResponse = res;
|
||||
if (res.login_redirect_override) {
|
||||
if (!document.cookie.split(';').filter((item) => item.includes('userLoggedIn=true')).length && !window.location.href.includes('/#/login')) {
|
||||
window.location.replace(res.login_redirect_override);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
request.fail(() => callback());
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs the language off of navigator for browser compatibility.
|
||||
* If the language isn't set, then it falls back to the DEFAULT_LOCALE. The
|
||||
|
||||
@@ -40,6 +40,10 @@ export default ['i18n', function(i18n) {
|
||||
ALLOW_OAUTH2_FOR_EXTERNAL_USERS: {
|
||||
type: 'toggleSwitch',
|
||||
},
|
||||
LOGIN_REDIRECT_OVERRIDE: {
|
||||
type: 'text',
|
||||
reset: 'LOGIN_REDIRECT_OVERRIDE'
|
||||
},
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS: {
|
||||
type: 'text',
|
||||
reset: 'ACCESS_TOKEN_EXPIRE_SECONDS'
|
||||
|
||||
@@ -7,8 +7,8 @@ function AddController ($state, models, strings) {
|
||||
vm.panelTitle = strings.get('state.ADD_BREADCRUMB_LABEL');
|
||||
|
||||
vm.docs = {
|
||||
url: 'https://docs.ansible.com/ansible-tower/latest/html/administration/clustering.html',
|
||||
help_text: vm.strings.get('tooltips.DOCS_HELP_TEXT')
|
||||
url: 'https://docs.ansible.com/ansible-tower/latest/html/userguide/instance_groups.html',
|
||||
help_text: vm.strings.get('tooltips.IG_DOCS_HELP_TEXT')
|
||||
};
|
||||
|
||||
vm.tab = {
|
||||
|
||||
@@ -17,8 +17,8 @@ function EditController ($rootScope, $state, models, strings) {
|
||||
vm.panelTitle = instanceGroup.get('name');
|
||||
|
||||
vm.docs = {
|
||||
url: 'https://docs.ansible.com/ansible-tower/latest/html/administration/clustering.html',
|
||||
help_text: vm.strings.get('tooltips.DOCS_HELP_TEXT')
|
||||
url: 'https://docs.ansible.com/ansible-tower/latest/html/userguide/instance_groups.html',
|
||||
help_text: vm.strings.get('tooltips.IG_DOCS_HELP_TEXT')
|
||||
};
|
||||
|
||||
vm.tab = {
|
||||
|
||||
@@ -10,6 +10,11 @@ function AddContainerGroupController(ToJSON, $scope, $state, models, strings, i1
|
||||
vm.panelTitle = strings.get('state.ADD_CONTAINER_GROUP_BREADCRUMB_LABEL');
|
||||
vm.lookUpTitle = strings.get('container.LOOK_UP_TITLE');
|
||||
|
||||
vm.docs = {
|
||||
url: 'https://docs.ansible.com/ansible-tower/latest/html/administration/external_execution_envs.html#ag-container-groups',
|
||||
help_text: vm.strings.get('tooltips.CG_DOCS_HELP_TEXT')
|
||||
};
|
||||
|
||||
vm.form = instanceGroup.createFormSchema('post');
|
||||
vm.form.name.required = true;
|
||||
delete vm.form.name.help_text;
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
</div>
|
||||
</a>
|
||||
<at-panel>
|
||||
<at-panel-heading title="{{:: vm.panelTitle }}"></at-panel-heading>
|
||||
<at-panel-heading title="{{:: vm.panelTitle }}">
|
||||
<a class="Panel-docsLink" ng-href="{{ vm.docs.url }}" target="_blank" aw-tool-tip="{{:: vm.docs.help_text }}">
|
||||
<i class="fa fa-book"></i>
|
||||
</a>
|
||||
</at-panel-heading>
|
||||
<at-tab-group>
|
||||
<at-tab state="vm.tab.details">{{:: vm.strings.get('tab.DETAILS') }}</at-tab>
|
||||
<at-tab state="vm.tab.jobs">{{:: vm.strings.get('tab.JOBS') }}</at-tab>
|
||||
|
||||
@@ -23,6 +23,11 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string
|
||||
vm.panelTitle = EditContainerGroupDataset.data.name;
|
||||
vm.lookUpTitle = strings.get('container.LOOK_UP_TITLE');
|
||||
|
||||
vm.docs = {
|
||||
url: 'https://docs.ansible.com/ansible-tower/latest/html/administration/external_execution_envs.html#ag-container-groups',
|
||||
help_text: vm.strings.get('tooltips.CG_DOCS_HELP_TEXT')
|
||||
};
|
||||
|
||||
vm.form = instanceGroup.createFormSchema('post');
|
||||
vm.switchDisabled = false;
|
||||
vm.form.disabled = !instanceGroup.has('options', 'actions.PUT');
|
||||
|
||||
@@ -32,7 +32,8 @@ function InstanceGroupsStrings(BaseString) {
|
||||
ns.tooltips = {
|
||||
ADD_INSTANCE_GROUP: t.s('Create a new Instance Group'),
|
||||
ASSOCIATE_INSTANCES: t.s('Associate an existing Instance'),
|
||||
DOCS_HELP_TEXT: t.s('Instance Groups Help')
|
||||
IG_DOCS_HELP_TEXT: t.s('Instance Groups Help'),
|
||||
CG_DOCS_HELP_TEXT: t.s('Container Groups Help')
|
||||
};
|
||||
|
||||
ns.instance = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user