mirror of
https://github.com/ansible/awx.git
synced 2026-03-04 10:11:05 -03:30
Compare commits
561 Commits
21.0.0
...
thenets/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f25309078 | ||
|
|
1afa49f3ff | ||
|
|
6f88ea1dc7 | ||
|
|
f9428c10b9 | ||
|
|
bb7509498e | ||
|
|
8a06ffbe15 | ||
|
|
8ad948f268 | ||
|
|
73f808dee7 | ||
|
|
fecab52f86 | ||
|
|
609c67d85e | ||
|
|
8828ea706e | ||
|
|
4070ef3f33 | ||
|
|
39f6e2fa32 | ||
|
|
1dfdff4a9e | ||
|
|
310e354164 | ||
|
|
6d207d2490 | ||
|
|
01037fa561 | ||
|
|
61f3e5cbed | ||
|
|
44995e944a | ||
|
|
d3f15f5784 | ||
|
|
2437a84b48 | ||
|
|
696f099940 | ||
|
|
3f0f538c40 | ||
|
|
66529d0f70 | ||
|
|
974f845059 | ||
|
|
b4ef687b60 | ||
|
|
2ef531b2dc | ||
|
|
125801ec5b | ||
|
|
691d9d7dc4 | ||
|
|
5ca898541f | ||
|
|
24821ff030 | ||
|
|
99815f8962 | ||
|
|
d752e6ce6d | ||
|
|
457dd890cb | ||
|
|
4fbf5e9e2f | ||
|
|
687b4ac71d | ||
|
|
a1b364f80c | ||
|
|
ff49cc5636 | ||
|
|
9946e644c8 | ||
|
|
1ed7a50755 | ||
|
|
9f3396d867 | ||
|
|
bcd018707a | ||
|
|
a462978433 | ||
|
|
6d11003975 | ||
|
|
017e474325 | ||
|
|
5d717af778 | ||
|
|
8d08ac559d | ||
|
|
4e24867a0b | ||
|
|
2b4b8839d1 | ||
|
|
dba33f9ef5 | ||
|
|
db2649d7ba | ||
|
|
edc3da85cc | ||
|
|
2357e24d1d | ||
|
|
e4d1056450 | ||
|
|
37d9c9eb1b | ||
|
|
d42a85714a | ||
|
|
88bf03c6bf | ||
|
|
4b8a56be39 | ||
|
|
2aa99234f4 | ||
|
|
bf9f1b1d56 | ||
|
|
704e4781d9 | ||
|
|
4a8613ce4c | ||
|
|
e87fabe6bb | ||
|
|
532aa83555 | ||
|
|
d87bb973d5 | ||
|
|
a72da3bd1a | ||
|
|
56df3f0c2a | ||
|
|
e0c59d12c1 | ||
|
|
7645cc2707 | ||
|
|
6719010050 | ||
|
|
ccd46a1c0f | ||
|
|
cc1e349ea8 | ||
|
|
e509d5f1de | ||
|
|
4fca27c664 | ||
|
|
51be22aebd | ||
|
|
54b21e5872 | ||
|
|
85beb9eb70 | ||
|
|
56739ac246 | ||
|
|
1ea3c564df | ||
|
|
621833ef0e | ||
|
|
16be38bb54 | ||
|
|
c5976e2584 | ||
|
|
3c51cb130f | ||
|
|
c649809eb2 | ||
|
|
43a53f41dd | ||
|
|
a3fef27002 | ||
|
|
cfc1255812 | ||
|
|
278db2cdde | ||
|
|
64157f7207 | ||
|
|
9e8ba6ca09 | ||
|
|
268ab128d7 | ||
|
|
fad5934c1e | ||
|
|
c9e3873a28 | ||
|
|
6a19aabd44 | ||
|
|
11e63e2e89 | ||
|
|
7c885dcadb | ||
|
|
b84a192bad | ||
|
|
35afb10add | ||
|
|
13fc845bcc | ||
|
|
f1bd1f1dfc | ||
|
|
67c9e1a0cb | ||
|
|
f6da9a5073 | ||
|
|
38a0950f46 | ||
|
|
55d295c2a6 | ||
|
|
be45919ee4 | ||
|
|
0a4a9f96c2 | ||
|
|
1ae1da3f9c | ||
|
|
cae2c06190 | ||
|
|
993dd61024 | ||
|
|
ea07aef73e | ||
|
|
268a4ad32d | ||
|
|
3712af4df8 | ||
|
|
8cf75fce8c | ||
|
|
46be2d9e5b | ||
|
|
998000bfbe | ||
|
|
43a50cc62c | ||
|
|
30f556f845 | ||
|
|
c5985c4c81 | ||
|
|
a9170236e1 | ||
|
|
85a5b58d18 | ||
|
|
6fb3c8daa8 | ||
|
|
a0103acbef | ||
|
|
f7e6a32444 | ||
|
|
7bbc256ff1 | ||
|
|
64f62d6755 | ||
|
|
b4cfe868fb | ||
|
|
8d8681580d | ||
|
|
8892cf2622 | ||
|
|
585d3f4e2a | ||
|
|
2c9a0444e6 | ||
|
|
279cebcef3 | ||
|
|
e6f8852b05 | ||
|
|
d06a3f060d | ||
|
|
957b2b7188 | ||
|
|
b94b3a1e91 | ||
|
|
7776a81e22 | ||
|
|
bf89093fac | ||
|
|
76d76d13b0 | ||
|
|
e603c23b40 | ||
|
|
8af4dd5988 | ||
|
|
0a47d05d26 | ||
|
|
b3eb9e0193 | ||
|
|
b26d2ab0e9 | ||
|
|
7eb0c7dd28 | ||
|
|
236c1df676 | ||
|
|
ff118f2177 | ||
|
|
29d91da1d2 | ||
|
|
ad08eafb9a | ||
|
|
431b9370df | ||
|
|
3e93eefe62 | ||
|
|
782667a34e | ||
|
|
90524611ea | ||
|
|
583086ae62 | ||
|
|
19c24cba10 | ||
|
|
5290c692c1 | ||
|
|
90a19057d5 | ||
|
|
a05c328081 | ||
|
|
6d9e353a4e | ||
|
|
82c062eab9 | ||
|
|
c0d59801d5 | ||
|
|
93ea8a0919 | ||
|
|
67f1ab2237 | ||
|
|
71be8fadcb | ||
|
|
c41becec13 | ||
|
|
6d0d8e57a4 | ||
|
|
6446b627ad | ||
|
|
fcebd188a6 | ||
|
|
1fca505b61 | ||
|
|
a0e9c30b4a | ||
|
|
bc94dc0257 | ||
|
|
65771b7629 | ||
|
|
86a67abbce | ||
|
|
d555093325 | ||
|
|
95a099acc5 | ||
|
|
d1fc2702ec | ||
|
|
3aa8320fc7 | ||
|
|
734899228b | ||
|
|
87f729c642 | ||
|
|
62fc3994fb | ||
|
|
0d097964be | ||
|
|
9f8b3948e1 | ||
|
|
1ce8240192 | ||
|
|
1bcfc8f28e | ||
|
|
71925de902 | ||
|
|
54057f1c80 | ||
|
|
ae388d943d | ||
|
|
2d310dc4e5 | ||
|
|
fe1a767f4f | ||
|
|
8c6581d80a | ||
|
|
33e445f4f6 | ||
|
|
9bcb60d9e0 | ||
|
|
40109d58c7 | ||
|
|
2ef3f5f9e8 | ||
|
|
389c4a3180 | ||
|
|
bee48671cd | ||
|
|
21f551f48a | ||
|
|
cbb019ed09 | ||
|
|
bf5dfdaba7 | ||
|
|
0f7f8af9b8 | ||
|
|
0237402390 | ||
|
|
84d7fa882d | ||
|
|
cd2fae3471 | ||
|
|
8be64145f9 | ||
|
|
23d28fb4c8 | ||
|
|
aeffd6f393 | ||
|
|
ab6b4bad03 | ||
|
|
769c253ac2 | ||
|
|
8031b3d402 | ||
|
|
bd93ac7edd | ||
|
|
37ff9913d3 | ||
|
|
9cb44a7e52 | ||
|
|
6279295541 | ||
|
|
de17cff39c | ||
|
|
22ca49e673 | ||
|
|
008a4b4d30 | ||
|
|
8d4089c7f3 | ||
|
|
e296d0adad | ||
|
|
df38650aee | ||
|
|
401b30b3ed | ||
|
|
20cc54694c | ||
|
|
e6ec0952fb | ||
|
|
db1dec3a98 | ||
|
|
1853d3850e | ||
|
|
1e57c84383 | ||
|
|
3cf120c6a7 | ||
|
|
fd671ecc9d | ||
|
|
a0d5f1fb03 | ||
|
|
ff882a322b | ||
|
|
b70231f7d0 | ||
|
|
93d1aa0a9d | ||
|
|
c586f8bbc6 | ||
|
|
26912a06d1 | ||
|
|
218a3d333b | ||
|
|
d2013bd416 | ||
|
|
6a3f9690b0 | ||
|
|
d59b6f834c | ||
|
|
cbea36745e | ||
|
|
ae7be525e1 | ||
|
|
5062ce1e61 | ||
|
|
566665ee8c | ||
|
|
96423af160 | ||
|
|
a01bef8d2c | ||
|
|
0522233892 | ||
|
|
63ea6bb5b3 | ||
|
|
c2715d7c29 | ||
|
|
783b744bdb | ||
|
|
f7982a0d64 | ||
|
|
2147ac226e | ||
|
|
6cc22786bc | ||
|
|
861a9f581e | ||
|
|
e57a8183ba | ||
|
|
8a7163ffad | ||
|
|
439b351c95 | ||
|
|
14afab918e | ||
|
|
ef8d4e73ae | ||
|
|
61f483ae32 | ||
|
|
21bed7473d | ||
|
|
31d8ddcf84 | ||
|
|
9419270897 | ||
|
|
f755d93a58 | ||
|
|
05df2ebad2 | ||
|
|
b44442c460 | ||
|
|
989b389ba4 | ||
|
|
5bd4aade0e | ||
|
|
470910b612 | ||
|
|
dbb81551c8 | ||
|
|
f7c5cb2979 | ||
|
|
babd6f0975 | ||
|
|
7bcceb7e98 | ||
|
|
c92619a2dc | ||
|
|
923cc671db | ||
|
|
db105c21e4 | ||
|
|
372aa36207 | ||
|
|
173318764b | ||
|
|
1dd535a859 | ||
|
|
e7d37b26f3 | ||
|
|
f4ef7d6927 | ||
|
|
7cbe112e4e | ||
|
|
c441db2aab | ||
|
|
fb292d9706 | ||
|
|
35a5f93182 | ||
|
|
116dc0c480 | ||
|
|
b87ba1c53d | ||
|
|
59691b71bb | ||
|
|
cc0bb3e401 | ||
|
|
7ef90bd9f4 | ||
|
|
f820c49b82 | ||
|
|
ac62d86f2a | ||
|
|
b9e67e7972 | ||
|
|
48a2ebd48c | ||
|
|
ee13ddd87d | ||
|
|
3fcf7429a3 | ||
|
|
51a8790d56 | ||
|
|
c231e4d05e | ||
|
|
987e5a084d | ||
|
|
70ac7b2920 | ||
|
|
bda335cb19 | ||
|
|
30c060cb27 | ||
|
|
9b0a2b0b76 | ||
|
|
2f82b75748 | ||
|
|
84fcd2ff00 | ||
|
|
3bc0c53e37 | ||
|
|
bc2dbcfce8 | ||
|
|
876edf54a3 | ||
|
|
b31bf8fab1 | ||
|
|
e8b2998578 | ||
|
|
8a92a01652 | ||
|
|
705f86f8cf | ||
|
|
9ab6a6d57e | ||
|
|
791eb4c1e1 | ||
|
|
870ca29388 | ||
|
|
816518cfab | ||
|
|
9e981583a6 | ||
|
|
d6fb8d6cd7 | ||
|
|
7dbf5f7138 | ||
|
|
aaec9487e6 | ||
|
|
96fa881df1 | ||
|
|
b7057fdc3e | ||
|
|
2679c99cad | ||
|
|
ea3a8d4912 | ||
|
|
63d9cd7b57 | ||
|
|
b692bbaa12 | ||
|
|
186af73e5d | ||
|
|
fddf292d47 | ||
|
|
1180634ba7 | ||
|
|
9abdafe101 | ||
|
|
48ebcd5918 | ||
|
|
fe6d0ce9cc | ||
|
|
62dabcae63 | ||
|
|
0b63af8d4d | ||
|
|
b05ebe9623 | ||
|
|
c836fafb61 | ||
|
|
96330f608d | ||
|
|
23aaf5b3ad | ||
|
|
a3e86dcd73 | ||
|
|
81b8028ea2 | ||
|
|
a4bfb032ff | ||
|
|
2704b202bf | ||
|
|
550d9d5e42 | ||
|
|
ab2d05a07d | ||
|
|
4543f6935f | ||
|
|
78d3d6dc94 | ||
|
|
02e7424f51 | ||
|
|
2d6ca4cbb1 | ||
|
|
e244644a1d | ||
|
|
d216457c09 | ||
|
|
20a1da61c0 | ||
|
|
bf7ab1ede7 | ||
|
|
3b6b449545 | ||
|
|
781cf531e6 | ||
|
|
9b7475247c | ||
|
|
44dc7f8d1d | ||
|
|
60eaf9e235 | ||
|
|
f5102ed24d | ||
|
|
309178e4e2 | ||
|
|
76ffdbb993 | ||
|
|
d8037618c8 | ||
|
|
e94e15977c | ||
|
|
f37951249f | ||
|
|
9191079dda | ||
|
|
fdd560747d | ||
|
|
faa5df19ca | ||
|
|
5f9326b131 | ||
|
|
8e389d40b4 | ||
|
|
e62c77e783 | ||
|
|
48b3a43ec2 | ||
|
|
5f783fd5ee | ||
|
|
e112cf93c2 | ||
|
|
d9f26a411e | ||
|
|
ea84e7a491 | ||
|
|
7fab619fed | ||
|
|
699a35b88a | ||
|
|
8095adb945 | ||
|
|
8d36712860 | ||
|
|
0db34d0498 | ||
|
|
7ab254e5e3 | ||
|
|
dd7ab459e2 | ||
|
|
33df2e8aa4 | ||
|
|
39b8fd433b | ||
|
|
c31d74100d | ||
|
|
3af89c1e2b | ||
|
|
1d35bba8c3 | ||
|
|
c3c3e24875 | ||
|
|
ab9c97b158 | ||
|
|
5e700c992d | ||
|
|
b548ad21a9 | ||
|
|
127016d36b | ||
|
|
3d0391173b | ||
|
|
ce560bcd5f | ||
|
|
d553c37d7d | ||
|
|
8a5e89e24b | ||
|
|
8c3e289170 | ||
|
|
9364c8e562 | ||
|
|
5831949ebf | ||
|
|
7fe98a670f | ||
|
|
6f68f3cba6 | ||
|
|
4dc956c76f | ||
|
|
11a56117eb | ||
|
|
10eed6286a | ||
|
|
d36befd9ce | ||
|
|
0c4ddc7f6f | ||
|
|
3ef9679de3 | ||
|
|
d36441489a | ||
|
|
d26c12dd7c | ||
|
|
7fa7ed3658 | ||
|
|
2c68e7a3d2 | ||
|
|
0c9b1c3c79 | ||
|
|
e10b0e513e | ||
|
|
68c66edada | ||
|
|
6eb17e7af7 | ||
|
|
9a24da3098 | ||
|
|
8ed0543b8b | ||
|
|
73a84444d1 | ||
|
|
451767c179 | ||
|
|
8366386126 | ||
|
|
997686a2ea | ||
|
|
f02212b1fe | ||
|
|
2ba68ef5d0 | ||
|
|
2041665880 | ||
|
|
1e6ca01686 | ||
|
|
e15a76e7aa | ||
|
|
64db44acef | ||
|
|
9972389a8d | ||
|
|
e0b1274eee | ||
|
|
973facebba | ||
|
|
df649e2c56 | ||
|
|
a778017efb | ||
|
|
6a9305818e | ||
|
|
2669904c72 | ||
|
|
35529b5eeb | ||
|
|
d55ed8713c | ||
|
|
7973f28bed | ||
|
|
8189964cce | ||
|
|
ee4c901dc7 | ||
|
|
78220cad82 | ||
|
|
40279bc6c0 | ||
|
|
f6fb46d99e | ||
|
|
954b32941e | ||
|
|
48b016802c | ||
|
|
35aa5dd79f | ||
|
|
237402068c | ||
|
|
31dda6e9d6 | ||
|
|
bca6e00e37 | ||
|
|
1c9b4af61d | ||
|
|
eba4a3f1c2 | ||
|
|
0ae9fe3624 | ||
|
|
1b662fcca5 | ||
|
|
cfdba959dd | ||
|
|
78660ad0a2 | ||
|
|
70697869d7 | ||
|
|
10e55108ef | ||
|
|
d4223b8877 | ||
|
|
9537d148d7 | ||
|
|
a133a14b70 | ||
|
|
4ca9e9577b | ||
|
|
44986fad36 | ||
|
|
eb2fca86b6 | ||
|
|
458a1fc035 | ||
|
|
6e87b29e92 | ||
|
|
be1d0c525c | ||
|
|
0787cb4fc2 | ||
|
|
19063a2d90 | ||
|
|
e8e2f820d2 | ||
|
|
aaad634483 | ||
|
|
dfa4127bae | ||
|
|
f3725c714a | ||
|
|
cef3ed01ac | ||
|
|
fc1a3f46f9 | ||
|
|
bfa5feb51b | ||
|
|
4c0813bd69 | ||
|
|
9b0b0f2a5f | ||
|
|
e87c121f8f | ||
|
|
65dfc424bc | ||
|
|
dfea9cc526 | ||
|
|
0d97a0364a | ||
|
|
1da57a4a12 | ||
|
|
b73078e9db | ||
|
|
b17f22cd38 | ||
|
|
7b225057ce | ||
|
|
8242078c06 | ||
|
|
a86740c3c9 | ||
|
|
cbde56549d | ||
|
|
385a94866c | ||
|
|
21972c91dd | ||
|
|
36d3f9afdb | ||
|
|
df2d303ab0 | ||
|
|
05eba350b7 | ||
|
|
1e12e12578 | ||
|
|
bbdab82433 | ||
|
|
f7be6b6423 | ||
|
|
ba358eaa4f | ||
|
|
162e09972f | ||
|
|
2cfccdbe16 | ||
|
|
434fa7b7be | ||
|
|
2f8bdf1eab | ||
|
|
e1705738a1 | ||
|
|
4cfb8fe482 | ||
|
|
d52d2af4b4 | ||
|
|
97fd3832d4 | ||
|
|
3cedd0e0bd | ||
|
|
507b1898ce | ||
|
|
e3fe9010b7 | ||
|
|
2c350b8b90 | ||
|
|
d74e258079 | ||
|
|
b03cabd314 | ||
|
|
6a63af83c0 | ||
|
|
452744b67e | ||
|
|
703a68d4fe | ||
|
|
557893e4b0 | ||
|
|
d7051fb6ce | ||
|
|
867c50da19 | ||
|
|
e8d76ec272 | ||
|
|
c102c61532 | ||
|
|
adb2b0da89 | ||
|
|
3610008699 | ||
|
|
3b44838dde | ||
|
|
0205d7deab | ||
|
|
dd47829bdb | ||
|
|
e7e72d13a9 | ||
|
|
4bbdf1ec8a | ||
|
|
4596df449e | ||
|
|
2b0846e8a2 | ||
|
|
ecbb636ba1 | ||
|
|
e3aed9dad4 | ||
|
|
213983a322 | ||
|
|
2977084787 | ||
|
|
b6362a63cc | ||
|
|
7517ba820b | ||
|
|
29d60844a8 | ||
|
|
41b0607d7e | ||
|
|
13f7166a30 | ||
|
|
0cc9b84ead | ||
|
|
68ee4311bf | ||
|
|
6e6c3f676e | ||
|
|
c67f50831b | ||
|
|
50ef234bd6 | ||
|
|
2bef5ce09b | ||
|
|
a49c4796f4 | ||
|
|
9eab9586e5 | ||
|
|
cd35787a86 | ||
|
|
cbe84ff4f3 | ||
|
|
410f38eccf | ||
|
|
b885fc2d86 | ||
|
|
4c93f5794a | ||
|
|
456bb75dcb | ||
|
|
02fd8b0d20 | ||
|
|
fbe6c80f86 | ||
|
|
3d5f302d10 | ||
|
|
856a2c1734 | ||
|
|
4277b73438 | ||
|
|
2888f9f8d0 | ||
|
|
68221cdcbe | ||
|
|
f50501cc2a | ||
|
|
c84fac65e0 | ||
|
|
d64c457b3d | ||
|
|
1bd5a880dc | ||
|
|
47d5a89f40 | ||
|
|
6060e7e29f | ||
|
|
29702400f1 | ||
|
|
b562d5cc88 | ||
|
|
dfde30798e |
@@ -1,3 +1,2 @@
|
|||||||
awx/ui/node_modules
|
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.git
|
.git
|
||||||
|
|||||||
17
.github/BOTMETA.yml
vendored
17
.github/BOTMETA.yml
vendored
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
files:
|
|
||||||
awx/ui/:
|
|
||||||
labels: component:ui
|
|
||||||
maintainers: $team_ui
|
|
||||||
awx/api/:
|
|
||||||
labels: component:api
|
|
||||||
maintainers: $team_api
|
|
||||||
awx/main/:
|
|
||||||
labels: component:api
|
|
||||||
maintainers: $team_api
|
|
||||||
installer/:
|
|
||||||
labels: component:installer
|
|
||||||
|
|
||||||
macros:
|
|
||||||
team_api: wwitzel3 matburt chrismeyersfsu cchurch AlanCoding ryanpetrello rooftopcellist
|
|
||||||
team_ui: jlmitch5 jaredevantabor mabashian marshmalien benthomasson jakemcdermott
|
|
||||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1 +0,0 @@
|
|||||||
workflows/e2e_test.yml @tiagodread @shanemcd @jakemcdermott
|
|
||||||
26
.github/ISSUE_TEMPLATE.md
vendored
26
.github/ISSUE_TEMPLATE.md
vendored
@@ -6,17 +6,37 @@ practices regarding responsible disclosure, see
|
|||||||
https://www.ansible.com/security
|
https://www.ansible.com/security
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
PLEASE DO NOT USE A BLANK TEMPLATE IN THE AWX REPO.
|
||||||
|
This is a legacy template used for internal testing ONLY.
|
||||||
|
|
||||||
|
Any issues opened will this template will be automatically closed.
|
||||||
|
|
||||||
|
Instead use the bug or feature request.
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
##### ISSUE TYPE
|
##### ISSUE TYPE
|
||||||
<!--- Pick one below and delete the rest: -->
|
<!--- Pick one below and delete the rest: -->
|
||||||
- Bug Report
|
- Breaking Change
|
||||||
- Feature Idea
|
- New or Enhanced Feature
|
||||||
- Documentation
|
- Bug, Docs Fix or other nominal change
|
||||||
|
|
||||||
|
|
||||||
##### COMPONENT NAME
|
##### COMPONENT NAME
|
||||||
<!-- Pick the area of AWX for this issue, you can have multiple, delete the rest: -->
|
<!-- Pick the area of AWX for this issue, you can have multiple, delete the rest: -->
|
||||||
- API
|
- API
|
||||||
- UI
|
- UI
|
||||||
- Collection
|
- Collection
|
||||||
|
- Docs
|
||||||
|
- CLI
|
||||||
|
- Other
|
||||||
|
|
||||||
|
|
||||||
##### SUMMARY
|
##### SUMMARY
|
||||||
<!-- Briefly describe the problem. -->
|
<!-- Briefly describe the problem. -->
|
||||||
|
|||||||
17
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
17
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,13 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Create a report to help us improve
|
description: "🐞 Create a report to help us improve"
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Issues are for **concrete, actionable bugs and feature requests** only. For debugging help or technical support, please use:
|
Bug Report issues are for **concrete, actionable bugs** only.
|
||||||
- The #ansible-awx channel on irc.libera.chat
|
For debugging help or technical support, please see the [Get Involved section of our README](https://github.com/ansible/awx#get-involved)
|
||||||
- The awx project mailing list, https://groups.google.com/forum/#!forum/awx-project
|
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
@@ -24,7 +23,7 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
label: Summary
|
label: Bug Summary
|
||||||
description: Briefly describe the problem.
|
description: Briefly describe the problem.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
@@ -45,6 +44,9 @@ body:
|
|||||||
- label: UI
|
- label: UI
|
||||||
- label: API
|
- label: API
|
||||||
- label: Docs
|
- label: Docs
|
||||||
|
- label: Collection
|
||||||
|
- label: CLI
|
||||||
|
- label: Other
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: awx-install-method
|
id: awx-install-method
|
||||||
@@ -57,9 +59,8 @@ body:
|
|||||||
- minikube
|
- minikube
|
||||||
- openshift
|
- openshift
|
||||||
- minishift
|
- minishift
|
||||||
- docker on linux
|
- docker development environment
|
||||||
- docker for mac
|
- N/A
|
||||||
- boot2docker
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
12
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
12
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: For debugging help or technical support
|
||||||
|
url: https://github.com/ansible/awx#get-involved
|
||||||
|
about: For general debugging or technical support please see the Get Involved section of our readme.
|
||||||
|
- name: 📝 Ansible Code of Conduct
|
||||||
|
url: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html?utm_medium=github&utm_source=issue_template_chooser
|
||||||
|
about: AWX uses the Ansible Code of Conduct; ❤ Be nice to other members of the community. ☮ Behave.
|
||||||
|
- name: 💼 For Enterprise
|
||||||
|
url: https://www.ansible.com/products/engine?utm_medium=github&utm_source=issue_template_chooser
|
||||||
|
about: Red Hat offers support for the Ansible Automation Platform
|
||||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
name: "✨ Feature request"
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
|
|
||||||
---
|
|
||||||
<!-- Issues are for **concrete, actionable bugs and feature requests** only - if you're just asking for debugging help or technical support, please use:
|
|
||||||
|
|
||||||
- http://web.libera.chat/?channels=#ansible-awx
|
|
||||||
- https://groups.google.com/forum/#!forum/awx-project
|
|
||||||
|
|
||||||
We have to limit this because of limited volunteer time to respond to issues! -->
|
|
||||||
|
|
||||||
##### ISSUE TYPE
|
|
||||||
- Feature Idea
|
|
||||||
|
|
||||||
##### SUMMARY
|
|
||||||
<!-- Briefly describe the problem or desired enhancement. -->
|
|
||||||
88
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
88
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: ✨ Feature request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Feature Request issues are for **feature requests** only.
|
||||||
|
For debugging help or technical support, please see the [Get Involved section of our README](https://github.com/ansible/awx#get-involved)
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: Please confirm the following
|
||||||
|
options:
|
||||||
|
- label: I agree to follow this project's [code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html).
|
||||||
|
required: true
|
||||||
|
- label: I have checked the [current issues](https://github.com/ansible/awx/issues) for duplicates.
|
||||||
|
required: true
|
||||||
|
- label: I understand that AWX is open source software provided for free and that I might not receive a timely response.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: feature-type
|
||||||
|
attributes:
|
||||||
|
label: Feature type
|
||||||
|
description: >-
|
||||||
|
What kind of feature is this?
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- "New Feature"
|
||||||
|
- "Enhancement to Existing Feature"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Feature Summary
|
||||||
|
description: Briefly describe the desired enhancement.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: components
|
||||||
|
attributes:
|
||||||
|
label: Select the relevant components
|
||||||
|
options:
|
||||||
|
- label: UI
|
||||||
|
- label: API
|
||||||
|
- label: Docs
|
||||||
|
- label: Collection
|
||||||
|
- label: CLI
|
||||||
|
- label: Other
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: >-
|
||||||
|
Describe the necessary steps to understand the scenario of the requested enhancement.
|
||||||
|
Include all the steps that will help the developer and QE team understand what you are requesting.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current-results
|
||||||
|
attributes:
|
||||||
|
label: Current results
|
||||||
|
description: What is currently happening on the scenario?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: sugested-results
|
||||||
|
attributes:
|
||||||
|
label: Sugested feature result
|
||||||
|
description: What is the result this new feature will bring?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-information
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Please provide any other information you think is relevant that could help us understand your feature request.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
---
|
|
||||||
name: "\U0001F525 Security bug report"
|
|
||||||
about: How to report security vulnerabilities
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For all security related bugs, email security@ansible.com instead of using this issue tracker and you will receive a prompt response.
|
|
||||||
|
|
||||||
For more information on the Ansible community's practices regarding responsible disclosure, see https://www.ansible.com/security
|
|
||||||
9
.github/LABEL_MAP.md
vendored
9
.github/LABEL_MAP.md
vendored
@@ -1,9 +0,0 @@
|
|||||||
Bug Report: type:bug
|
|
||||||
Bugfix Pull Request: type:bug
|
|
||||||
Feature Request: type:enhancement
|
|
||||||
Feature Pull Request: type:enhancement
|
|
||||||
UI: component:ui
|
|
||||||
API: component:api
|
|
||||||
Installer: component:installer
|
|
||||||
Docs Pull Request: component:docs
|
|
||||||
Documentation: component:docs
|
|
||||||
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,11 +1,3 @@
|
|||||||
<!--- changelog-entry
|
|
||||||
# Fill in 'msg' below to have an entry automatically added to the next release changelog.
|
|
||||||
# Leaving 'msg' blank will not generate a changelog entry for this PR.
|
|
||||||
# Please ensure this is a simple (and readable) one-line string.
|
|
||||||
---
|
|
||||||
msg: ""
|
|
||||||
-->
|
|
||||||
|
|
||||||
##### SUMMARY
|
##### SUMMARY
|
||||||
<!--- Describe the change, including rationale and design decisions -->
|
<!--- Describe the change, including rationale and design decisions -->
|
||||||
|
|
||||||
@@ -17,15 +9,18 @@ the change does.
|
|||||||
|
|
||||||
##### ISSUE TYPE
|
##### ISSUE TYPE
|
||||||
<!--- Pick one below and delete the rest: -->
|
<!--- Pick one below and delete the rest: -->
|
||||||
- Feature Pull Request
|
- Breaking Change
|
||||||
- Bugfix Pull Request
|
- New or Enhanced Feature
|
||||||
- Docs Pull Request
|
- Bug, Docs Fix or other nominal change
|
||||||
|
|
||||||
##### COMPONENT NAME
|
##### COMPONENT NAME
|
||||||
<!--- Name of the module/plugin/module/task -->
|
<!--- Name of the module/plugin/module/task -->
|
||||||
- API
|
- API
|
||||||
- UI
|
- UI
|
||||||
- Collection
|
- Collection
|
||||||
|
- CLI
|
||||||
|
- Docs
|
||||||
|
- Other
|
||||||
|
|
||||||
##### AWX VERSION
|
##### AWX VERSION
|
||||||
<!--- Paste verbatim output from `make VERSION` between quotes below -->
|
<!--- Paste verbatim output from `make VERSION` between quotes below -->
|
||||||
|
|||||||
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/awx/ui"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
allow:
|
||||||
|
- dependency-type: "production"
|
||||||
|
reviewers:
|
||||||
|
- "AlexSCorey"
|
||||||
|
- "keithjgrant"
|
||||||
|
- "kialam"
|
||||||
|
- "mabashian"
|
||||||
|
- "marshmalien"
|
||||||
|
labels:
|
||||||
|
- "component:ui"
|
||||||
|
- "dependencies"
|
||||||
|
target-branch: "devel"
|
||||||
8
.github/issue_labeler.yml
vendored
8
.github/issue_labeler.yml
vendored
@@ -1,12 +1,16 @@
|
|||||||
needs_triage:
|
needs_triage:
|
||||||
- '.*'
|
- '.*'
|
||||||
"type:bug":
|
"type:bug":
|
||||||
- "Please confirm the following"
|
- "Bug Summary"
|
||||||
"type:enhancement":
|
"type:enhancement":
|
||||||
- "Feature Idea"
|
- "Feature Summary"
|
||||||
"component:ui":
|
"component:ui":
|
||||||
- "\\[X\\] UI"
|
- "\\[X\\] UI"
|
||||||
"component:api":
|
"component:api":
|
||||||
- "\\[X\\] API"
|
- "\\[X\\] API"
|
||||||
"component:docs":
|
"component:docs":
|
||||||
- "\\[X\\] Docs"
|
- "\\[X\\] Docs"
|
||||||
|
"component:awx_collection":
|
||||||
|
- "\\[X\\] Collection"
|
||||||
|
"component:cli":
|
||||||
|
- "\\[X\\] awxkit"
|
||||||
|
|||||||
17
.github/pr_labeler.yml
vendored
17
.github/pr_labeler.yml
vendored
@@ -1,14 +1,19 @@
|
|||||||
"component:api":
|
"component:api":
|
||||||
- any: ['awx/**/*', '!awx/ui/*']
|
- any: ["awx/**/*", "!awx/ui/**"]
|
||||||
|
|
||||||
"component:ui":
|
"component:ui":
|
||||||
- any: ['awx/ui/**/*']
|
- any: ["awx/ui/**/*"]
|
||||||
|
|
||||||
"component:docs":
|
"component:docs":
|
||||||
- any: ['docs/**/*']
|
- any: ["docs/**/*"]
|
||||||
|
|
||||||
"component:cli":
|
"component:cli":
|
||||||
- any: ['awxkit/**/*']
|
- any: ["awxkit/**/*"]
|
||||||
|
|
||||||
"component:collection":
|
"component:awx_collection":
|
||||||
- any: ['awx_collection/**/*']
|
- any: ["awx_collection/**/*"]
|
||||||
|
|
||||||
|
"dependencies":
|
||||||
|
- any: ["awx/ui/package.json"]
|
||||||
|
- any: ["awx/requirements/*.txt"]
|
||||||
|
- any: ["awx/requirements/requirements.in"]
|
||||||
|
|||||||
111
.github/triage_replies.md
vendored
111
.github/triage_replies.md
vendored
@@ -3,29 +3,112 @@
|
|||||||
- Hello, we think your question is answered in our FAQ. Does this: https://www.ansible.com/products/awx-project/faq cover your question?
|
- Hello, we think your question is answered in our FAQ. Does this: https://www.ansible.com/products/awx-project/faq cover your question?
|
||||||
- You can find the latest documentation here: https://docs.ansible.com/automation-controller/latest/html/userguide/index.html
|
- You can find the latest documentation here: https://docs.ansible.com/automation-controller/latest/html/userguide/index.html
|
||||||
|
|
||||||
## Visit our mailing list
|
|
||||||
- Hello, your question seems like a good one to ask on our mailing list at https://groups.google.com/g/awx-project. You can also join #ansible-awx on https://libera.chat/ and ask your question there.
|
|
||||||
|
|
||||||
## Create an issue
|
|
||||||
- Hello, thanks for reaching out on list. We think this merits an issue on our Github, https://github.com/ansible/awx/issues. If you could open an issue up on Github it will get tagged and integrated into our planning and workflow. All future work will be tracked there.
|
|
||||||
|
|
||||||
## Create a Pull Request
|
## PRs/Issues
|
||||||
- Hello, we think your idea is good, please consider contributing a PR for this, following our contributing guidelines: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
|
|
||||||
|
|
||||||
## Receptor
|
### Visit our mailing list
|
||||||
|
- Hello, this appears to be less of a bug report or feature request and more of a question. Could you please ask this on our mailing list? See https://github.com/ansible/awx/#get-involved for information for ways to connect with us.
|
||||||
|
|
||||||
|
### Denied Submission
|
||||||
|
|
||||||
|
- Hi! \
|
||||||
|
\
|
||||||
|
Thanks very much for your submission to AWX. It means a lot to us that you have taken time to contribute. \
|
||||||
|
\
|
||||||
|
At this time we do not want to merge this PR. Our reasons for this are: \
|
||||||
|
\
|
||||||
|
(A) INSERT ITEM HERE \
|
||||||
|
\
|
||||||
|
Please know that we are always up for discussion but this project is very active. Because of this, we're unlikely to see comments made on closed PRs, and we lock them after some time. If you or anyone else has any further questions, please let us know by using any of the communication methods listed in the page below: \
|
||||||
|
\
|
||||||
|
https://github.com/ansible/awx/#get-involved \
|
||||||
|
\
|
||||||
|
In the future, sometimes starting a discussion on the development list prior to implementing a feature can make getting things included a little easier, but it is not always necessary. \
|
||||||
|
\
|
||||||
|
Thank you once again for this and your interest in AWX!
|
||||||
|
|
||||||
|
|
||||||
|
### No Progress Issue
|
||||||
|
- Hi! \
|
||||||
|
\
|
||||||
|
Thank you very much for for this issue. It means a lot to us that you have taken time to contribute by opening this report. \
|
||||||
|
\
|
||||||
|
On this issue, there were comments added but it has been some time since then without response. At this time we are closing this issue. If you get time to address the comments we can reopen the issue if you can contact us by using any of the communication methods listed in the page below: \
|
||||||
|
\
|
||||||
|
https://github.com/ansible/awx/#get-involved \
|
||||||
|
\
|
||||||
|
Thank you once again for this and your interest in AWX!
|
||||||
|
|
||||||
|
|
||||||
|
### No Progress PR
|
||||||
|
- Hi! \
|
||||||
|
\
|
||||||
|
Thank you very much for your submission to AWX. It means a lot to us that you have taken time to contribute. \
|
||||||
|
\
|
||||||
|
On this PR, changes were requested but it has been some time since then. We think this PR has merit but without the requested changes we are unable to merge it. At this time we are closing your PR. If you get time to address the changes you are welcome to open another PR or we can reopen this PR upon request if you contact us by using any of the communication methods listed in the page below: \
|
||||||
|
\
|
||||||
|
https://github.com/ansible/awx/#get-involved \
|
||||||
|
\
|
||||||
|
Thank you once again for this and your interest in AWX!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Common
|
||||||
|
|
||||||
|
### Give us more info
|
||||||
|
- Hello, we'd love to help, but we need a little more information about the problem you're having. Screenshots, log outputs, or any reproducers would be very helpful.
|
||||||
|
|
||||||
|
### Code of Conduct
|
||||||
|
- Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html
|
||||||
|
|
||||||
|
### EE Contents / Community General
|
||||||
|
- Hello. The awx-ee contains the collections and dependencies needed for supported AWX features to function. Anything beyond that (like the community.general package) will require you to build your own EE. For information on how to do that, see https://ansible-builder.readthedocs.io/en/stable/ \
|
||||||
|
\
|
||||||
|
The Ansible Community is looking at building an EE that corresponds to all of the collections inside the ansible package. That may help you if and when it happens; see https://github.com/ansible-community/community-topics/issues/31 for details.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Mailing List Triage
|
||||||
|
|
||||||
|
### Create an issue
|
||||||
|
- Hello, thanks for reaching out on list. We think this merits an issue on our Github, https://github.com/ansible/awx/issues. If you could open an issue up on Github it will get tagged and integrated into our planning and workflow. All future work will be tracked there. Issues should include as much information as possible, including screenshots, log outputs, or any reproducers.
|
||||||
|
|
||||||
|
### Create a Pull Request
|
||||||
|
- Hello, we think your idea is good! Please consider contributing a PR for this following our contributing guidelines: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
|
||||||
|
|
||||||
|
### Receptor
|
||||||
- You can find the receptor docs here: https://receptor.readthedocs.io/en/latest/
|
- You can find the receptor docs here: https://receptor.readthedocs.io/en/latest/
|
||||||
- Hello, your issue seems related to receptor, could you please open an issue in the receptor repository? https://github.com/ansible/receptor. Thanks!
|
- Hello, your issue seems related to receptor. Could you please open an issue in the receptor repository? https://github.com/ansible/receptor. Thanks!
|
||||||
|
|
||||||
## Ansible Engine not AWX
|
### Ansible Engine not AWX
|
||||||
- Hello, your question seems to be about Ansible development, not about AWX. Try asking on the Ansible-devel specific mailing list: https://groups.google.com/g/ansible-devel
|
- Hello, your question seems to be about Ansible development, not about AWX. Try asking on the Ansible-devel specific mailing list: https://groups.google.com/g/ansible-devel
|
||||||
- Hello, your question seems to be about using Ansible, not about AWX. https://groups.google.com/g/ansible-project is the best place to visit for user questions about Ansible. Thanks!
|
- Hello, your question seems to be about using Ansible, not about AWX. https://groups.google.com/g/ansible-project is the best place to visit for user questions about Ansible. Thanks!
|
||||||
|
|
||||||
## Ansible Galaxy not AWX
|
### Ansible Galaxy not AWX
|
||||||
- Hey there, that sounds like an FAQ question, did this: https://www.ansible.com/products/awx-project/faq cover your question?
|
- Hey there. That sounds like an FAQ question. Did this: https://www.ansible.com/products/awx-project/faq cover your question?
|
||||||
|
|
||||||
## Contributing Guidelines
|
### Contributing Guidelines
|
||||||
- AWX: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
|
- AWX: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
|
||||||
- AWX-Operator: https://github.com/ansible/awx-operator/blob/devel/CONTRIBUTING.md
|
- AWX-Operator: https://github.com/ansible/awx-operator/blob/devel/CONTRIBUTING.md
|
||||||
|
|
||||||
## Code of Conduct
|
### Oracle AWX
|
||||||
- Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html
|
We'd be happy to help if you can reproduce this with AWX since we do not have Oracle's Linux Automation Manager. If you need help with this specific version of Oracles Linux Automation Manager you will need to contact your Oracle for support.
|
||||||
|
|
||||||
|
### AWX Release
|
||||||
|
Subject: Announcing AWX Xa.Ya.za and AWX-Operator Xb.Yb.zb
|
||||||
|
|
||||||
|
- Hi all, \
|
||||||
|
\
|
||||||
|
We're happy to announce that the next release of AWX, version <b>`Xa.Ya.za`</b> is now available! \
|
||||||
|
In addition AWX Operator version <b>`Xb.Yb.zb`</b> has also been released! \
|
||||||
|
\
|
||||||
|
Please see the releases pages for more details: \
|
||||||
|
AWX: https://github.com/ansible/awx/releases/tag/Xa.Ya.za \
|
||||||
|
Operator: https://github.com/ansible/awx-operator/releases/tag/Xb.Yb.zb \
|
||||||
|
\
|
||||||
|
The AWX team.
|
||||||
|
|
||||||
|
## Try latest version
|
||||||
|
- Hello, this issue pertains to an older version of AWX. Try upgrading to the latest version and let us know if that resolves your issue.
|
||||||
|
|||||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -111,6 +111,15 @@ jobs:
|
|||||||
repository: ansible/awx-operator
|
repository: ansible/awx-operator
|
||||||
path: awx-operator
|
path: awx-operator
|
||||||
|
|
||||||
|
- name: Get python version from Makefile
|
||||||
|
working-directory: awx
|
||||||
|
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Install python ${{ env.py_version }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.py_version }}
|
||||||
|
|
||||||
- name: Install playbook dependencies
|
- name: Install playbook dependencies
|
||||||
run: |
|
run: |
|
||||||
python3 -m pip install docker
|
python3 -m pip install docker
|
||||||
|
|||||||
31
.github/workflows/label_issue.yml
vendored
31
.github/workflows/label_issue.yml
vendored
@@ -19,3 +19,34 @@ jobs:
|
|||||||
not-before: 2021-12-07T07:00:00Z
|
not-before: 2021-12-07T07:00:00Z
|
||||||
configuration-path: .github/issue_labeler.yml
|
configuration-path: .github/issue_labeler.yml
|
||||||
enable-versioned-regex: 0
|
enable-versioned-regex: 0
|
||||||
|
|
||||||
|
community:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Label Issue - Community
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
- name: Install python requests
|
||||||
|
run: pip install requests
|
||||||
|
- name: Check if user is a member of Ansible org
|
||||||
|
uses: jannekem/run-python-script-action@v1
|
||||||
|
id: check_user
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
import requests
|
||||||
|
headers = {'Accept': 'application/vnd.github+json', 'Authorization': 'token ${{ secrets.GITHUB_TOKEN }}'}
|
||||||
|
response = requests.get('${{ fromJson(toJson(github.event.issue.user.url)) }}/orgs?per_page=100', headers=headers)
|
||||||
|
is_member = False
|
||||||
|
for org in response.json():
|
||||||
|
if org['login'] == 'ansible':
|
||||||
|
is_member = True
|
||||||
|
if is_member:
|
||||||
|
print("User is member")
|
||||||
|
else:
|
||||||
|
print("User is community")
|
||||||
|
- name: Add community label if not a member
|
||||||
|
if: contains(steps.check_user.outputs.stdout, 'community')
|
||||||
|
uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90
|
||||||
|
with:
|
||||||
|
add-labels: "community"
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
31
.github/workflows/label_pr.yml
vendored
31
.github/workflows/label_pr.yml
vendored
@@ -18,3 +18,34 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
configuration-path: .github/pr_labeler.yml
|
configuration-path: .github/pr_labeler.yml
|
||||||
|
|
||||||
|
community:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Label PR - Community
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
- name: Install python requests
|
||||||
|
run: pip install requests
|
||||||
|
- name: Check if user is a member of Ansible org
|
||||||
|
uses: jannekem/run-python-script-action@v1
|
||||||
|
id: check_user
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
import requests
|
||||||
|
headers = {'Accept': 'application/vnd.github+json', 'Authorization': 'token ${{ secrets.GITHUB_TOKEN }}'}
|
||||||
|
response = requests.get('${{ fromJson(toJson(github.event.pull_request.user.url)) }}/orgs?per_page=100', headers=headers)
|
||||||
|
is_member = False
|
||||||
|
for org in response.json():
|
||||||
|
if org['login'] == 'ansible':
|
||||||
|
is_member = True
|
||||||
|
if is_member:
|
||||||
|
print("User is member")
|
||||||
|
else:
|
||||||
|
print("User is community")
|
||||||
|
- name: Add community label if not a member
|
||||||
|
if: contains(steps.check_user.outputs.stdout, 'community')
|
||||||
|
uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90
|
||||||
|
with:
|
||||||
|
add-labels: "community"
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
45
.github/workflows/pr_body_check.yml
vendored
Normal file
45
.github/workflows/pr_body_check.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: PR Check
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ github.base_ref || 'devel' }}
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited, reopened, synchronize]
|
||||||
|
jobs:
|
||||||
|
pr-check:
|
||||||
|
name: Scan PR description for semantic versioning keywords
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Write PR body to a file
|
||||||
|
run: |
|
||||||
|
cat >> pr.body << __SOME_RANDOM_PR_EOF__
|
||||||
|
${{ github.event.pull_request.body }}
|
||||||
|
__SOME_RANDOM_PR_EOF__
|
||||||
|
|
||||||
|
- name: Display the received body for troubleshooting
|
||||||
|
run: cat pr.body
|
||||||
|
|
||||||
|
# We want to write these out individually just incase the options were joined on a single line
|
||||||
|
- name: Check for each of the lines
|
||||||
|
run: |
|
||||||
|
grep "Bug, Docs Fix or other nominal change" pr.body > Z
|
||||||
|
grep "New or Enhanced Feature" pr.body > Y
|
||||||
|
grep "Breaking Change" pr.body > X
|
||||||
|
exit 0
|
||||||
|
# We exit 0 and set the shell to prevent the returns from the greps from failing this step
|
||||||
|
# See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference
|
||||||
|
shell: bash {0}
|
||||||
|
|
||||||
|
- name: Check for exactly one item
|
||||||
|
run: |
|
||||||
|
if [ $(cat X Y Z | wc -l) != 1 ] ; then
|
||||||
|
echo "The PR body must contain exactly one of [ 'Bug, Docs Fix or other nominal change', 'New or Enhanced Feature', 'Breaking Change' ]"
|
||||||
|
echo "We counted $(cat X Y Z | wc -l)"
|
||||||
|
echo "See the default PR body for examples"
|
||||||
|
exit 255;
|
||||||
|
else
|
||||||
|
exit 0;
|
||||||
|
fi
|
||||||
4
.github/workflows/promote.yml
vendored
4
.github/workflows/promote.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python${{ env.py_version }} -m pip install wheel twine
|
python${{ env.py_version }} -m pip install wheel twine setuptools-scm
|
||||||
|
|
||||||
- name: Set official collection namespace
|
- name: Set official collection namespace
|
||||||
run: echo collection_namespace=awx >> $GITHUB_ENV
|
run: echo collection_namespace=awx >> $GITHUB_ENV
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build collection and publish to galaxy
|
- name: Build collection and publish to galaxy
|
||||||
run: |
|
run: |
|
||||||
COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection
|
COLLECTION_TEMPLATE_VERSION=true COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection
|
||||||
ansible-galaxy collection publish \
|
ansible-galaxy collection publish \
|
||||||
--token=${{ secrets.GALAXY_TOKEN }} \
|
--token=${{ secrets.GALAXY_TOKEN }} \
|
||||||
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz
|
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz
|
||||||
|
|||||||
13
.github/workflows/stage.yml
vendored
13
.github/workflows/stage.yml
vendored
@@ -100,23 +100,10 @@ jobs:
|
|||||||
AWX_TEST_IMAGE: ${{ github.repository }}
|
AWX_TEST_IMAGE: ${{ github.repository }}
|
||||||
AWX_TEST_VERSION: ${{ github.event.inputs.version }}
|
AWX_TEST_VERSION: ${{ github.event.inputs.version }}
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
uses: shanemcd/simple-changelog-generator@v1
|
|
||||||
id: changelog
|
|
||||||
with:
|
|
||||||
repo: "${{ github.repository }}"
|
|
||||||
|
|
||||||
- name: Write changelog to file
|
|
||||||
run: |
|
|
||||||
cat << 'EOF' > /tmp/awx-changelog
|
|
||||||
${{ steps.changelog.outputs.changelog }}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Create draft release for AWX
|
- name: Create draft release for AWX
|
||||||
working-directory: awx
|
working-directory: awx
|
||||||
run: |
|
run: |
|
||||||
ansible-playbook -v tools/ansible/stage.yml \
|
ansible-playbook -v tools/ansible/stage.yml \
|
||||||
-e changelog_path=/tmp/awx-changelog \
|
|
||||||
-e repo=${{ github.repository }} \
|
-e repo=${{ github.repository }} \
|
||||||
-e awx_image=ghcr.io/${{ github.repository }} \
|
-e awx_image=ghcr.io/${{ github.repository }} \
|
||||||
-e version=${{ github.event.inputs.version }} \
|
-e version=${{ github.event.inputs.version }} \
|
||||||
|
|||||||
26
.github/workflows/update_dependabot_prs.yml
vendored
Normal file
26
.github/workflows/update_dependabot_prs.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Dependency Pr Update
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [labeled, opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr-check:
|
||||||
|
name: Update Dependabot Prs
|
||||||
|
if: contains(github.event.pull_request.labels.*.name, 'dependencies') && contains(github.event.pull_request.labels.*.name, 'component:ui')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout branch
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Update PR Body
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
BRANCH: ${{github.event.pull_request.head.ref}}
|
||||||
|
PR: ${{github.event.pull_request}}
|
||||||
|
run: |
|
||||||
|
gh pr checkout ${{ env.BRANCH }}
|
||||||
|
gh pr edit --body "${{ env.PR }}\nBug, Docs Fix or other nominal change"
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,7 +38,6 @@ awx/ui/build
|
|||||||
awx/ui/.env.local
|
awx/ui/.env.local
|
||||||
awx/ui/instrumented
|
awx/ui/instrumented
|
||||||
rsyslog.pid
|
rsyslog.pid
|
||||||
tools/prometheus/data
|
|
||||||
tools/docker-compose/ansible/awx_dump.sql
|
tools/docker-compose/ansible/awx_dump.sql
|
||||||
tools/docker-compose/Dockerfile
|
tools/docker-compose/Dockerfile
|
||||||
tools/docker-compose/_build
|
tools/docker-compose/_build
|
||||||
|
|||||||
@@ -19,16 +19,17 @@ Have questions about this document or anything not covered here? Come chat with
|
|||||||
- [Purging containers and images](#purging-containers-and-images)
|
- [Purging containers and images](#purging-containers-and-images)
|
||||||
- [Pre commit hooks](#pre-commit-hooks)
|
- [Pre commit hooks](#pre-commit-hooks)
|
||||||
- [What should I work on?](#what-should-i-work-on)
|
- [What should I work on?](#what-should-i-work-on)
|
||||||
|
- [Translations](#translations)
|
||||||
- [Submitting Pull Requests](#submitting-pull-requests)
|
- [Submitting Pull Requests](#submitting-pull-requests)
|
||||||
- [PR Checks run by Zuul](#pr-checks-run-by-zuul)
|
|
||||||
- [Reporting Issues](#reporting-issues)
|
- [Reporting Issues](#reporting-issues)
|
||||||
|
- [Getting Help](#getting-help)
|
||||||
|
|
||||||
## Things to know prior to submitting code
|
## Things to know prior to submitting code
|
||||||
|
|
||||||
- All code submissions are done through pull requests against the `devel` branch.
|
- All code submissions are done through pull requests against the `devel` branch.
|
||||||
- You must use `git commit --signoff` for any commit to be merged, and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md).
|
- You must use `git commit --signoff` for any commit to be merged, and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md).
|
||||||
- Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason.
|
- Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason.
|
||||||
- If collaborating with someone else on the same branch, consider using `--force-with-lease` instead of `--force`. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt
|
- If collaborating with someone else on the same branch, consider using `--force-with-lease` instead of `--force`. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see [git push docs](https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt).
|
||||||
- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.libera.chat, and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed.
|
- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.libera.chat, and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed.
|
||||||
- We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
|
- We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
|
||||||
|
|
||||||
@@ -42,8 +43,7 @@ The AWX development environment workflow and toolchain uses Docker and the docke
|
|||||||
|
|
||||||
Prior to starting the development services, you'll need `docker` and `docker-compose`. On Linux, you can generally find these in your distro's packaging, but you may find that Docker themselves maintain a separate repo that tracks more closely to the latest releases.
|
Prior to starting the development services, you'll need `docker` and `docker-compose`. On Linux, you can generally find these in your distro's packaging, but you may find that Docker themselves maintain a separate repo that tracks more closely to the latest releases.
|
||||||
|
|
||||||
For macOS and Windows, we recommend [Docker for Mac](https://www.docker.com/docker-mac) and [Docker for Windows](https://www.docker.com/docker-windows)
|
For macOS and Windows, we recommend [Docker for Mac](https://www.docker.com/docker-mac) and [Docker for Windows](https://www.docker.com/docker-windows) respectively.
|
||||||
respectively.
|
|
||||||
|
|
||||||
For Linux platforms, refer to the following from Docker:
|
For Linux platforms, refer to the following from Docker:
|
||||||
|
|
||||||
@@ -79,17 +79,13 @@ See the [README.md](./tools/docker-compose/README.md) for docs on how to build t
|
|||||||
|
|
||||||
### Building API Documentation
|
### Building API Documentation
|
||||||
|
|
||||||
AWX includes support for building [Swagger/OpenAPI
|
AWX includes support for building [Swagger/OpenAPI documentation](https://swagger.io). To build the documentation locally, run:
|
||||||
documentation](https://swagger.io). To build the documentation locally, run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
(container)/awx_devel$ make swagger
|
(container)/awx_devel$ make swagger
|
||||||
```
|
```
|
||||||
|
|
||||||
This will write a file named `swagger.json` that contains the API specification
|
This will write a file named `swagger.json` that contains the API specification in OpenAPI format. A variety of online tools are available for translating this data into more consumable formats (such as HTML). http://editor.swagger.io is an example of one such service.
|
||||||
in OpenAPI format. A variety of online tools are available for translating
|
|
||||||
this data into more consumable formats (such as HTML). http://editor.swagger.io
|
|
||||||
is an example of one such service.
|
|
||||||
|
|
||||||
### Accessing the AWX web interface
|
### Accessing the AWX web interface
|
||||||
|
|
||||||
@@ -115,20 +111,30 @@ While you can use environment variables to skip the pre-commit hooks GitHub will
|
|||||||
|
|
||||||
## What should I work on?
|
## What should I work on?
|
||||||
|
|
||||||
|
We have a ["good first issue" label](https://github.com/ansible/awx/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) we put on some issues that might be a good starting point for new contributors.
|
||||||
|
|
||||||
|
Fixing bugs and updating the documentation are always appreciated, so reviewing the backlog of issues is always a good place to start.
|
||||||
|
|
||||||
For feature work, take a look at the current [Enhancements](https://github.com/ansible/awx/issues?q=is%3Aissue+is%3Aopen+label%3Atype%3Aenhancement).
|
For feature work, take a look at the current [Enhancements](https://github.com/ansible/awx/issues?q=is%3Aissue+is%3Aopen+label%3Atype%3Aenhancement).
|
||||||
|
|
||||||
If it has someone assigned to it then that person is the person responsible for working the enhancement. If you feel like you could contribute then reach out to that person.
|
If it has someone assigned to it then that person is the person responsible for working the enhancement. If you feel like you could contribute then reach out to that person.
|
||||||
|
|
||||||
Fixing bugs, adding translations, and updating the documentation are always appreciated, so reviewing the backlog of issues is always a good place to start. For extra information on debugging tools, see [Debugging](./docs/debugging/).
|
**NOTES**
|
||||||
|
|
||||||
|
> Issue assignment will only be done for maintainers of the project. If you decide to work on an issue, please feel free to add a comment in the issue to let others know that you are working on it; but know that we will accept the first pull request from whomever is able to fix an issue. Once your PR is accepted we can add you as an assignee to an issue upon request.
|
||||||
|
|
||||||
**NOTE**
|
|
||||||
|
|
||||||
> If you work in a part of the codebase that is going through active development, your changes may be rejected, or you may be asked to `rebase`. A good idea before starting work is to have a discussion with us in the `#ansible-awx` channel on irc.libera.chat, or on the [mailing list](https://groups.google.com/forum/#!forum/awx-project).
|
> If you work in a part of the codebase that is going through active development, your changes may be rejected, or you may be asked to `rebase`. A good idea before starting work is to have a discussion with us in the `#ansible-awx` channel on irc.libera.chat, or on the [mailing list](https://groups.google.com/forum/#!forum/awx-project).
|
||||||
|
|
||||||
**NOTE**
|
|
||||||
|
|
||||||
> If you're planning to develop features or fixes for the UI, please review the [UI Developer doc](./awx/ui/README.md).
|
> If you're planning to develop features or fixes for the UI, please review the [UI Developer doc](./awx/ui/README.md).
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
At this time we do not accept PRs for adding additional language translations as we have an automated process for generating our translations. This is because translations require constant care as new strings are added and changed in the code base. Because of this the .po files are overwritten during every translation release cycle. We also can't support a lot of translations on AWX as its an open source project and each language adds time and cost to maintain. If you would like to see AWX translated into a new language please create an issue and ask others you know to upvote the issue. Our translation team will review the needs of the community and see what they can do around supporting additional language.
|
||||||
|
|
||||||
|
If you find an issue with an existing translation, please see the [Reporting Issues](#reporting-issues) section to open an issue and our translation team will work with you on a resolution.
|
||||||
|
|
||||||
|
|
||||||
## Submitting Pull Requests
|
## Submitting Pull Requests
|
||||||
|
|
||||||
Fixes and Features for AWX will go through the Github pull request process. Submit your pull request (PR) against the `devel` branch.
|
Fixes and Features for AWX will go through the Github pull request process. Submit your pull request (PR) against the `devel` branch.
|
||||||
@@ -152,28 +158,14 @@ We like to keep our commit history clean, and will require resubmission of pull
|
|||||||
|
|
||||||
Sometimes it might take us a while to fully review your PR. We try to keep the `devel` branch in good working order, and so we review requests carefully. Please be patient.
|
Sometimes it might take us a while to fully review your PR. We try to keep the `devel` branch in good working order, and so we review requests carefully. Please be patient.
|
||||||
|
|
||||||
All submitted PRs will have the linter and unit tests run against them via Zuul, and the status reported in the PR.
|
When your PR is initially submitted the checks will not be run until a maintainer allows them to be. Once a maintainer has done a quick review of your work the PR will have the linter and unit tests run against them via GitHub Actions, and the status reported in the PR.
|
||||||
|
|
||||||
## PR Checks run by Zuul
|
|
||||||
|
|
||||||
Zuul jobs for awx are defined in the [zuul-jobs](https://github.com/ansible/zuul-jobs) repo.
|
|
||||||
|
|
||||||
Zuul runs the following checks that must pass:
|
|
||||||
|
|
||||||
1. `tox-awx-api-lint`
|
|
||||||
2. `tox-awx-ui-lint`
|
|
||||||
3. `tox-awx-api`
|
|
||||||
4. `tox-awx-ui`
|
|
||||||
5. `tox-awx-swagger`
|
|
||||||
|
|
||||||
Zuul runs the following checks that are non-voting (can not pass but serve to inform PR reviewers):
|
|
||||||
|
|
||||||
1. `tox-awx-detect-schema-change`
|
|
||||||
This check generates the schema and diffs it against a reference copy of the `devel` version of the schema.
|
|
||||||
Reviewers should inspect the `job-output.txt.gz` related to the check if their is a failure (grep for `diff -u -b` to find beginning of diff).
|
|
||||||
If the schema change is expected and makes sense in relation to the changes made by the PR, then you are good to go!
|
|
||||||
If not, the schema changes should be fixed, but this decision must be enforced by reviewers.
|
|
||||||
|
|
||||||
## Reporting Issues
|
## Reporting Issues
|
||||||
|
|
||||||
We welcome your feedback, and encourage you to file an issue when you run into a problem. But before opening a new issues, we ask that you please view our [Issues guide](./ISSUES.md).
|
We welcome your feedback, and encourage you to file an issue when you run into a problem. But before opening a new issues, we ask that you please view our [Issues guide](./ISSUES.md).
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If you require additional assistance, please reach out to us at `#ansible-awx` on irc.libera.chat, or submit your question to the [mailing list](https://groups.google.com/forum/#!forum/awx-project).
|
||||||
|
|
||||||
|
For extra information on debugging tools, see [Debugging](./docs/debugging/).
|
||||||
|
|||||||
235
Makefile
235
Makefile
@@ -5,8 +5,8 @@ NPM_BIN ?= npm
|
|||||||
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
CHROMIUM_BIN=/tmp/chrome-linux/chrome
|
||||||
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
MANAGEMENT_COMMAND ?= awx-manage
|
MANAGEMENT_COMMAND ?= awx-manage
|
||||||
VERSION := $(shell $(PYTHON) setup.py --version)
|
VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py)
|
||||||
COLLECTION_VERSION := $(shell $(PYTHON) setup.py --version | cut -d . -f 1-3)
|
COLLECTION_VERSION := $(shell $(PYTHON) tools/scripts/scm_version.py | cut -d . -f 1-3)
|
||||||
|
|
||||||
# NOTE: This defaults the container image version to the branch that's active
|
# NOTE: This defaults the container image version to the branch that's active
|
||||||
COMPOSE_TAG ?= $(GIT_BRANCH)
|
COMPOSE_TAG ?= $(GIT_BRANCH)
|
||||||
@@ -15,6 +15,12 @@ MAIN_NODE_TYPE ?= hybrid
|
|||||||
KEYCLOAK ?= false
|
KEYCLOAK ?= false
|
||||||
# If set to true docker-compose will also start an ldap instance
|
# If set to true docker-compose will also start an ldap instance
|
||||||
LDAP ?= false
|
LDAP ?= false
|
||||||
|
# If set to true docker-compose will also start a splunk instance
|
||||||
|
SPLUNK ?= false
|
||||||
|
# If set to true docker-compose will also start a prometheus instance
|
||||||
|
PROMETHEUS ?= false
|
||||||
|
# If set to true docker-compose will also start a grafana instance
|
||||||
|
GRAFANA ?= false
|
||||||
|
|
||||||
VENV_BASE ?= /var/lib/awx/venv
|
VENV_BASE ?= /var/lib/awx/venv
|
||||||
|
|
||||||
@@ -43,52 +49,13 @@ I18N_FLAG_FILE = .i18n_built
|
|||||||
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
|
.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \
|
||||||
develop refresh adduser migrate dbchange \
|
develop refresh adduser migrate dbchange \
|
||||||
receiver test test_unit test_coverage coverage_html \
|
receiver test test_unit test_coverage coverage_html \
|
||||||
dev_build release_build sdist \
|
sdist \
|
||||||
ui-release ui-devel \
|
ui-release ui-devel \
|
||||||
VERSION PYTHON_VERSION docker-compose-sources \
|
VERSION PYTHON_VERSION docker-compose-sources \
|
||||||
.git/hooks/pre-commit
|
.git/hooks/pre-commit
|
||||||
|
|
||||||
clean-tmp:
|
|
||||||
rm -rf tmp/
|
|
||||||
|
|
||||||
clean-venv:
|
## convenience target to assert environment variables are defined
|
||||||
rm -rf venv/
|
|
||||||
|
|
||||||
clean-dist:
|
|
||||||
rm -rf dist
|
|
||||||
|
|
||||||
clean-schema:
|
|
||||||
rm -rf swagger.json
|
|
||||||
rm -rf schema.json
|
|
||||||
rm -rf reference-schema.json
|
|
||||||
|
|
||||||
clean-languages:
|
|
||||||
rm -f $(I18N_FLAG_FILE)
|
|
||||||
find ./awx/locale/ -type f -regex ".*\.mo$" -delete
|
|
||||||
|
|
||||||
# Remove temporary build files, compiled Python files.
|
|
||||||
clean: clean-ui clean-api clean-awxkit clean-dist
|
|
||||||
rm -rf awx/public
|
|
||||||
rm -rf awx/lib/site-packages
|
|
||||||
rm -rf awx/job_status
|
|
||||||
rm -rf awx/job_output
|
|
||||||
rm -rf reports
|
|
||||||
rm -rf tmp
|
|
||||||
rm -rf $(I18N_FLAG_FILE)
|
|
||||||
mkdir tmp
|
|
||||||
|
|
||||||
clean-api:
|
|
||||||
rm -rf build $(NAME)-$(VERSION) *.egg-info
|
|
||||||
find . -type f -regex ".*\.py[co]$$" -delete
|
|
||||||
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 awxkit/build/*
|
|
||||||
|
|
||||||
# convenience target to assert environment variables are defined
|
|
||||||
guard-%:
|
guard-%:
|
||||||
@if [ "$${$*}" = "" ]; then \
|
@if [ "$${$*}" = "" ]; then \
|
||||||
echo "The required environment variable '$*' is not set"; \
|
echo "The required environment variable '$*' is not set"; \
|
||||||
@@ -111,7 +78,7 @@ virtualenv_awx:
|
|||||||
fi; \
|
fi; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install third-party requirements needed for AWX's environment.
|
## Install third-party requirements needed for AWX's environment.
|
||||||
# this does not use system site packages intentionally
|
# this does not use system site packages intentionally
|
||||||
requirements_awx: virtualenv_awx
|
requirements_awx: virtualenv_awx
|
||||||
if [[ "$(PIP_OPTIONS)" == *"--no-index"* ]]; then \
|
if [[ "$(PIP_OPTIONS)" == *"--no-index"* ]]; then \
|
||||||
@@ -130,7 +97,7 @@ requirements_dev: requirements_awx requirements_awx_dev
|
|||||||
|
|
||||||
requirements_test: requirements
|
requirements_test: requirements
|
||||||
|
|
||||||
# "Install" awx package in development mode.
|
## "Install" awx package in development mode.
|
||||||
develop:
|
develop:
|
||||||
@if [ "$(VIRTUAL_ENV)" ]; then \
|
@if [ "$(VIRTUAL_ENV)" ]; then \
|
||||||
pip uninstall -y awx; \
|
pip uninstall -y awx; \
|
||||||
@@ -147,21 +114,21 @@ version_file:
|
|||||||
fi; \
|
fi; \
|
||||||
$(PYTHON) -c "import awx; print(awx.__version__)" > /var/lib/awx/.awx_version; \
|
$(PYTHON) -c "import awx; print(awx.__version__)" > /var/lib/awx/.awx_version; \
|
||||||
|
|
||||||
# Refresh development environment after pulling new code.
|
## Refresh development environment after pulling new code.
|
||||||
refresh: clean requirements_dev version_file develop migrate
|
refresh: clean requirements_dev version_file develop migrate
|
||||||
|
|
||||||
# Create Django superuser.
|
## Create Django superuser.
|
||||||
adduser:
|
adduser:
|
||||||
$(MANAGEMENT_COMMAND) createsuperuser
|
$(MANAGEMENT_COMMAND) createsuperuser
|
||||||
|
|
||||||
# Create database tables and apply any new migrations.
|
## Create database tables and apply any new migrations.
|
||||||
migrate:
|
migrate:
|
||||||
if [ "$(VENV_BASE)" ]; then \
|
if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
fi; \
|
fi; \
|
||||||
$(MANAGEMENT_COMMAND) migrate --noinput
|
$(MANAGEMENT_COMMAND) migrate --noinput
|
||||||
|
|
||||||
# Run after making changes to the models to create a new migration.
|
## Run after making changes to the models to create a new migration.
|
||||||
dbchange:
|
dbchange:
|
||||||
$(MANAGEMENT_COMMAND) makemigrations
|
$(MANAGEMENT_COMMAND) makemigrations
|
||||||
|
|
||||||
@@ -198,7 +165,7 @@ uwsgi: collectstatic
|
|||||||
--logformat "%(addr) %(method) %(uri) - %(proto) %(status)"
|
--logformat "%(addr) %(method) %(uri) - %(proto) %(status)"
|
||||||
|
|
||||||
awx-autoreload:
|
awx-autoreload:
|
||||||
@/awx_devel/tools/docker-compose/awx-autoreload /awx_devel "$(DEV_RELOAD_COMMAND)"
|
@/awx_devel/tools/docker-compose/awx-autoreload /awx_devel/awx "$(DEV_RELOAD_COMMAND)"
|
||||||
|
|
||||||
daphne:
|
daphne:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
@@ -212,7 +179,7 @@ wsbroadcast:
|
|||||||
fi; \
|
fi; \
|
||||||
$(PYTHON) manage.py run_wsbroadcast
|
$(PYTHON) manage.py run_wsbroadcast
|
||||||
|
|
||||||
# Run to start the background task dispatcher for development.
|
## Run to start the background task dispatcher for development.
|
||||||
dispatcher:
|
dispatcher:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
@@ -220,7 +187,7 @@ dispatcher:
|
|||||||
$(PYTHON) manage.py run_dispatcher
|
$(PYTHON) manage.py run_dispatcher
|
||||||
|
|
||||||
|
|
||||||
# Run to start the zeromq callback receiver
|
## Run to start the zeromq callback receiver
|
||||||
receiver:
|
receiver:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
@@ -267,12 +234,12 @@ api-lint:
|
|||||||
yamllint -s .
|
yamllint -s .
|
||||||
|
|
||||||
awx-link:
|
awx-link:
|
||||||
[ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/setup.py egg_info_dev
|
[ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/tools/scripts/egg_info_dev
|
||||||
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/$(PYTHON)/site-packages/awx.egg-link
|
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/$(PYTHON)/site-packages/awx.egg-link
|
||||||
|
|
||||||
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
|
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
|
||||||
PYTEST_ARGS ?= -n auto
|
PYTEST_ARGS ?= -n auto
|
||||||
# Run all API unit tests.
|
## Run all API unit tests.
|
||||||
test:
|
test:
|
||||||
if [ "$(VENV_BASE)" ]; then \
|
if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
@@ -286,6 +253,7 @@ COLLECTION_TEST_TARGET ?=
|
|||||||
COLLECTION_PACKAGE ?= awx
|
COLLECTION_PACKAGE ?= awx
|
||||||
COLLECTION_NAMESPACE ?= awx
|
COLLECTION_NAMESPACE ?= awx
|
||||||
COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE)
|
COLLECTION_INSTALL = ~/.ansible/collections/ansible_collections/$(COLLECTION_NAMESPACE)/$(COLLECTION_PACKAGE)
|
||||||
|
COLLECTION_TEMPLATE_VERSION ?= false
|
||||||
|
|
||||||
test_collection:
|
test_collection:
|
||||||
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
|
rm -f $(shell ls -d $(VENV_BASE)/awx/lib/python* | head -n 1)/no-global-site-packages.txt
|
||||||
@@ -313,7 +281,7 @@ awx_collection_build: $(shell find awx_collection -type f)
|
|||||||
-e collection_package=$(COLLECTION_PACKAGE) \
|
-e collection_package=$(COLLECTION_PACKAGE) \
|
||||||
-e collection_namespace=$(COLLECTION_NAMESPACE) \
|
-e collection_namespace=$(COLLECTION_NAMESPACE) \
|
||||||
-e collection_version=$(COLLECTION_VERSION) \
|
-e collection_version=$(COLLECTION_VERSION) \
|
||||||
-e '{"awx_template_version":false}'
|
-e '{"awx_template_version": $(COLLECTION_TEMPLATE_VERSION)}'
|
||||||
ansible-galaxy collection build awx_collection_build --force --output-path=awx_collection_build
|
ansible-galaxy collection build awx_collection_build --force --output-path=awx_collection_build
|
||||||
|
|
||||||
build_collection: awx_collection_build
|
build_collection: awx_collection_build
|
||||||
@@ -334,36 +302,99 @@ test_unit:
|
|||||||
fi; \
|
fi; \
|
||||||
py.test awx/main/tests/unit awx/conf/tests/unit awx/sso/tests/unit
|
py.test awx/main/tests/unit awx/conf/tests/unit awx/sso/tests/unit
|
||||||
|
|
||||||
# Run all API unit tests with coverage enabled.
|
## Run all API unit tests with coverage enabled.
|
||||||
test_coverage:
|
test_coverage:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
fi; \
|
fi; \
|
||||||
py.test --create-db --cov=awx --cov-report=xml --junitxml=./reports/junit.xml $(TEST_DIRS)
|
py.test --create-db --cov=awx --cov-report=xml --junitxml=./reports/junit.xml $(TEST_DIRS)
|
||||||
|
|
||||||
# Output test coverage as HTML (into htmlcov directory).
|
## Output test coverage as HTML (into htmlcov directory).
|
||||||
coverage_html:
|
coverage_html:
|
||||||
coverage html
|
coverage html
|
||||||
|
|
||||||
# Run API unit tests across multiple Python/Django versions with Tox.
|
## Run API unit tests across multiple Python/Django versions with Tox.
|
||||||
test_tox:
|
test_tox:
|
||||||
tox -v
|
tox -v
|
||||||
|
|
||||||
# Make fake data
|
|
||||||
DATA_GEN_PRESET = ""
|
DATA_GEN_PRESET = ""
|
||||||
|
## Make fake data
|
||||||
bulk_data:
|
bulk_data:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
fi; \
|
fi; \
|
||||||
$(PYTHON) tools/data_generators/rbac_dummy_data_generator.py --preset=$(DATA_GEN_PRESET)
|
$(PYTHON) tools/data_generators/rbac_dummy_data_generator.py --preset=$(DATA_GEN_PRESET)
|
||||||
|
|
||||||
|
# CLEANUP COMMANDS
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
## Clean everything. Including temporary build files, compiled Python files.
|
||||||
|
clean: clean-tmp clean-ui clean-api clean-awxkit clean-dist
|
||||||
|
rm -rf awx/public
|
||||||
|
rm -rf awx/lib/site-packages
|
||||||
|
rm -rf awx/job_status
|
||||||
|
rm -rf awx/job_output
|
||||||
|
rm -rf reports
|
||||||
|
rm -rf $(I18N_FLAG_FILE)
|
||||||
|
|
||||||
|
clean-tmp:
|
||||||
|
rm -rf tmp/
|
||||||
|
mkdir tmp
|
||||||
|
|
||||||
|
clean-venv:
|
||||||
|
rm -rf venv/
|
||||||
|
|
||||||
|
clean-dist:
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
|
clean-schema:
|
||||||
|
rm -rf swagger.json
|
||||||
|
rm -rf schema.json
|
||||||
|
rm -rf reference-schema.json
|
||||||
|
|
||||||
|
clean-languages:
|
||||||
|
rm -f $(I18N_FLAG_FILE)
|
||||||
|
find ./awx/locale/ -type f -regex ".*\.mo$" -delete
|
||||||
|
|
||||||
|
clean-api:
|
||||||
|
rm -rf build $(NAME)-$(VERSION) *.egg-info
|
||||||
|
find . -type f -regex ".*\.py[co]$$" -delete
|
||||||
|
find . -type d -name "__pycache__" -delete
|
||||||
|
rm -f awx/awx_test.sqlite3*
|
||||||
|
rm -rf requirements/vendor
|
||||||
|
rm -rf awx/projects
|
||||||
|
|
||||||
|
## Clean UI builded static files (alias for ui-clean)
|
||||||
|
clean-ui: ui-clean
|
||||||
|
|
||||||
|
## Clean temp build files from the awxkit
|
||||||
|
clean-awxkit:
|
||||||
|
rm -rf awxkit/*.egg-info awxkit/.tox awxkit/build/*
|
||||||
|
|
||||||
|
clean-docker-images:
|
||||||
|
IMAGES_TO_BE_DELETE=' \
|
||||||
|
quay.io/ansible/receptor \
|
||||||
|
quay.io/awx/awx_devel \
|
||||||
|
ansible/receptor \
|
||||||
|
postgres \
|
||||||
|
redis \
|
||||||
|
' && \
|
||||||
|
for IMAGE in $$IMAGES_TO_BE_DELETE; do \
|
||||||
|
echo "Removing image '$$IMAGE'" && \
|
||||||
|
IMAGE_IDS=$$(docker image ls -a | grep $$IMAGE | awk '{print $$3}') echo "oi" \
|
||||||
|
done
|
||||||
|
|
||||||
|
clean-docker-containers:
|
||||||
|
clean-docker-volumes:
|
||||||
|
|
||||||
|
|
||||||
# UI TASKS
|
# UI TASKS
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
|
|
||||||
UI_BUILD_FLAG_FILE = awx/ui/.ui-built
|
UI_BUILD_FLAG_FILE = awx/ui/.ui-built
|
||||||
|
|
||||||
clean-ui:
|
ui-clean:
|
||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
rm -rf awx/ui/node_modules
|
rm -rf awx/ui/node_modules
|
||||||
rm -rf awx/ui/build
|
rm -rf awx/ui/build
|
||||||
@@ -373,7 +404,8 @@ clean-ui:
|
|||||||
awx/ui/node_modules:
|
awx/ui/node_modules:
|
||||||
NODE_OPTIONS=--max-old-space-size=6144 $(NPM_BIN) --prefix awx/ui --loglevel warn ci
|
NODE_OPTIONS=--max-old-space-size=6144 $(NPM_BIN) --prefix awx/ui --loglevel warn ci
|
||||||
|
|
||||||
$(UI_BUILD_FLAG_FILE): awx/ui/node_modules
|
$(UI_BUILD_FLAG_FILE):
|
||||||
|
$(MAKE) awx/ui/node_modules
|
||||||
$(PYTHON) tools/scripts/compilemessages.py
|
$(PYTHON) tools/scripts/compilemessages.py
|
||||||
$(NPM_BIN) --prefix awx/ui --loglevel warn run compile-strings
|
$(NPM_BIN) --prefix awx/ui --loglevel warn run compile-strings
|
||||||
$(NPM_BIN) --prefix awx/ui --loglevel warn run build
|
$(NPM_BIN) --prefix awx/ui --loglevel warn run build
|
||||||
@@ -417,21 +449,13 @@ ui-test-general:
|
|||||||
$(NPM_BIN) run --prefix awx/ui pretest
|
$(NPM_BIN) run --prefix awx/ui pretest
|
||||||
$(NPM_BIN) run --prefix awx/ui/ test-general --runInBand
|
$(NPM_BIN) run --prefix awx/ui/ test-general --runInBand
|
||||||
|
|
||||||
# Build a pip-installable package into dist/ with a timestamped version number.
|
|
||||||
dev_build:
|
|
||||||
$(PYTHON) setup.py dev_build
|
|
||||||
|
|
||||||
# Build a pip-installable package into dist/ with the release version number.
|
|
||||||
release_build:
|
|
||||||
$(PYTHON) setup.py release_build
|
|
||||||
|
|
||||||
HEADLESS ?= no
|
HEADLESS ?= no
|
||||||
ifeq ($(HEADLESS), yes)
|
ifeq ($(HEADLESS), yes)
|
||||||
dist/$(SDIST_TAR_FILE):
|
dist/$(SDIST_TAR_FILE):
|
||||||
else
|
else
|
||||||
dist/$(SDIST_TAR_FILE): $(UI_BUILD_FLAG_FILE)
|
dist/$(SDIST_TAR_FILE): $(UI_BUILD_FLAG_FILE)
|
||||||
endif
|
endif
|
||||||
$(PYTHON) setup.py $(SDIST_COMMAND)
|
$(PYTHON) -m build -s
|
||||||
ln -sf $(SDIST_TAR_FILE) dist/awx.tar.gz
|
ln -sf $(SDIST_TAR_FILE) dist/awx.tar.gz
|
||||||
|
|
||||||
sdist: dist/$(SDIST_TAR_FILE)
|
sdist: dist/$(SDIST_TAR_FILE)
|
||||||
@@ -452,6 +476,11 @@ COMPOSE_OPTS ?=
|
|||||||
CONTROL_PLANE_NODE_COUNT ?= 1
|
CONTROL_PLANE_NODE_COUNT ?= 1
|
||||||
EXECUTION_NODE_COUNT ?= 2
|
EXECUTION_NODE_COUNT ?= 2
|
||||||
MINIKUBE_CONTAINER_GROUP ?= false
|
MINIKUBE_CONTAINER_GROUP ?= false
|
||||||
|
EXTRA_SOURCES_ANSIBLE_OPTS ?=
|
||||||
|
|
||||||
|
ifneq ($(ADMIN_PASSWORD),)
|
||||||
|
EXTRA_SOURCES_ANSIBLE_OPTS := -e admin_password=$(ADMIN_PASSWORD) $(EXTRA_SOURCES_ANSIBLE_OPTS)
|
||||||
|
endif
|
||||||
|
|
||||||
docker-compose-sources: .git/hooks/pre-commit
|
docker-compose-sources: .git/hooks/pre-commit
|
||||||
@if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\
|
@if [ $(MINIKUBE_CONTAINER_GROUP) = true ]; then\
|
||||||
@@ -466,7 +495,11 @@ docker-compose-sources: .git/hooks/pre-commit
|
|||||||
-e execution_node_count=$(EXECUTION_NODE_COUNT) \
|
-e execution_node_count=$(EXECUTION_NODE_COUNT) \
|
||||||
-e minikube_container_group=$(MINIKUBE_CONTAINER_GROUP) \
|
-e minikube_container_group=$(MINIKUBE_CONTAINER_GROUP) \
|
||||||
-e enable_keycloak=$(KEYCLOAK) \
|
-e enable_keycloak=$(KEYCLOAK) \
|
||||||
-e enable_ldap=$(LDAP)
|
-e enable_ldap=$(LDAP) \
|
||||||
|
-e enable_splunk=$(SPLUNK) \
|
||||||
|
-e enable_prometheus=$(PROMETHEUS) \
|
||||||
|
-e enable_grafana=$(GRAFANA) $(EXTRA_SOURCES_ANSIBLE_OPTS)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
docker-compose: awx/projects docker-compose-sources
|
docker-compose: awx/projects docker-compose-sources
|
||||||
@@ -500,7 +533,7 @@ docker-compose-container-group-clean:
|
|||||||
fi
|
fi
|
||||||
rm -rf tools/docker-compose-minikube/_sources/
|
rm -rf tools/docker-compose-minikube/_sources/
|
||||||
|
|
||||||
# Base development image build
|
## Base development image build
|
||||||
docker-compose-build:
|
docker-compose-build:
|
||||||
ansible-playbook tools/ansible/dockerfile.yml -e build_dev=True -e receptor_image=$(RECEPTOR_IMAGE)
|
ansible-playbook tools/ansible/dockerfile.yml -e build_dev=True -e receptor_image=$(RECEPTOR_IMAGE)
|
||||||
DOCKER_BUILDKIT=1 docker build -t $(DEVEL_IMAGE_NAME) \
|
DOCKER_BUILDKIT=1 docker build -t $(DEVEL_IMAGE_NAME) \
|
||||||
@@ -514,20 +547,17 @@ docker-clean:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
docker-clean-volumes: docker-compose-clean docker-compose-container-group-clean
|
docker-clean-volumes: docker-compose-clean docker-compose-container-group-clean
|
||||||
docker volume rm tools_awx_db
|
docker volume rm -f tools_awx_db tools_grafana_storage tools_prometheus_storage $(docker volume ls --filter name=tools_redis_socket_ -q)
|
||||||
|
|
||||||
docker-refresh: docker-clean docker-compose
|
docker-refresh: docker-clean docker-compose
|
||||||
|
|
||||||
# Docker Development Environment with Elastic Stack Connected
|
## Docker Development Environment with Elastic Stack Connected
|
||||||
docker-compose-elk: awx/projects docker-compose-sources
|
docker-compose-elk: awx/projects docker-compose-sources
|
||||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||||
|
|
||||||
docker-compose-cluster-elk: awx/projects docker-compose-sources
|
docker-compose-cluster-elk: awx/projects docker-compose-sources
|
||||||
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link-cluster.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
docker-compose -f tools/docker-compose/_sources/docker-compose.yml -f tools/elastic/docker-compose.logstash-link-cluster.yml -f tools/elastic/docker-compose.elastic-override.yml up --no-recreate
|
||||||
|
|
||||||
prometheus:
|
|
||||||
docker run -u0 --net=tools_default --link=`docker ps | egrep -o "tools_awx(_run)?_([^ ]+)?"`:awxweb --volume `pwd`/tools/prometheus:/prometheus --name prometheus -d -p 0.0.0.0:9090:9090 prom/prometheus --web.enable-lifecycle --config.file=/prometheus/prometheus.yml
|
|
||||||
|
|
||||||
docker-compose-container-group:
|
docker-compose-container-group:
|
||||||
MINIKUBE_CONTAINER_GROUP=true make docker-compose
|
MINIKUBE_CONTAINER_GROUP=true make docker-compose
|
||||||
|
|
||||||
@@ -558,26 +588,34 @@ Dockerfile.kube-dev: tools/ansible/roles/dockerfile/templates/Dockerfile.j2
|
|||||||
-e template_dest=_build_kube_dev \
|
-e template_dest=_build_kube_dev \
|
||||||
-e receptor_image=$(RECEPTOR_IMAGE)
|
-e receptor_image=$(RECEPTOR_IMAGE)
|
||||||
|
|
||||||
|
## Build awx_kube_devel image for development on local Kubernetes environment.
|
||||||
awx-kube-dev-build: Dockerfile.kube-dev
|
awx-kube-dev-build: Dockerfile.kube-dev
|
||||||
DOCKER_BUILDKIT=1 docker build -f Dockerfile.kube-dev \
|
DOCKER_BUILDKIT=1 docker build -f Dockerfile.kube-dev \
|
||||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||||
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) \
|
--cache-from=$(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) \
|
||||||
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
|
-t $(DEV_DOCKER_TAG_BASE)/awx_kube_devel:$(COMPOSE_TAG) .
|
||||||
|
|
||||||
|
## Build awx image for deployment on Kubernetes environment.
|
||||||
|
awx-kube-build: Dockerfile
|
||||||
|
DOCKER_BUILDKIT=1 docker build -f Dockerfile \
|
||||||
|
--build-arg VERSION=$(VERSION) \
|
||||||
|
--build-arg SETUPTOOLS_SCM_PRETEND_VERSION=$(VERSION) \
|
||||||
|
--build-arg HEADLESS=$(HEADLESS) \
|
||||||
|
-t $(DEV_DOCKER_TAG_BASE)/awx:$(COMPOSE_TAG) .
|
||||||
|
|
||||||
# Translation TASKS
|
# Translation TASKS
|
||||||
# --------------------------------------
|
# --------------------------------------
|
||||||
|
|
||||||
# generate UI .pot file, an empty template of strings yet to be translated
|
## generate UI .pot file, an empty template of strings yet to be translated
|
||||||
pot: $(UI_BUILD_FLAG_FILE)
|
pot: $(UI_BUILD_FLAG_FILE)
|
||||||
$(NPM_BIN) --prefix awx/ui --loglevel warn run extract-template --clean
|
$(NPM_BIN) --prefix awx/ui --loglevel warn run extract-template --clean
|
||||||
|
|
||||||
# generate UI .po files for each locale (will update translated strings for `en`)
|
## generate UI .po files for each locale (will update translated strings for `en`)
|
||||||
po: $(UI_BUILD_FLAG_FILE)
|
po: $(UI_BUILD_FLAG_FILE)
|
||||||
$(NPM_BIN) --prefix awx/ui --loglevel warn run extract-strings -- --clean
|
$(NPM_BIN) --prefix awx/ui --loglevel warn run extract-strings -- --clean
|
||||||
|
|
||||||
# generate API django .pot .po
|
LANG = "en_us"
|
||||||
LANG = "en-us"
|
## generate API django .pot .po
|
||||||
messages:
|
messages:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
@@ -586,3 +624,38 @@ messages:
|
|||||||
|
|
||||||
print-%:
|
print-%:
|
||||||
@echo $($*)
|
@echo $($*)
|
||||||
|
|
||||||
|
# HELP related targets
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
HELP_FILTER=.PHONY
|
||||||
|
|
||||||
|
## Display help targets
|
||||||
|
help:
|
||||||
|
@printf "Available targets:\n"
|
||||||
|
@make -s help/generate | grep -vE "\w($(HELP_FILTER))"
|
||||||
|
|
||||||
|
## Display help for all targets
|
||||||
|
help/all:
|
||||||
|
@printf "Available targets:\n"
|
||||||
|
@make -s help/generate
|
||||||
|
|
||||||
|
## Generate help output from MAKEFILE_LIST
|
||||||
|
help/generate:
|
||||||
|
@awk '/^[-a-zA-Z_0-9%:\\\.\/]+:/ { \
|
||||||
|
helpMessage = match(lastLine, /^## (.*)/); \
|
||||||
|
if (helpMessage) { \
|
||||||
|
helpCommand = $$1; \
|
||||||
|
helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \
|
||||||
|
gsub("\\\\", "", helpCommand); \
|
||||||
|
gsub(":+$$", "", helpCommand); \
|
||||||
|
printf " \x1b[32;01m%-35s\x1b[0m %s\n", helpCommand, helpMessage; \
|
||||||
|
} else { \
|
||||||
|
helpCommand = $$1; \
|
||||||
|
gsub("\\\\", "", helpCommand); \
|
||||||
|
gsub(":+$$", "", helpCommand); \
|
||||||
|
printf " \x1b[32;01m%-35s\x1b[0m %s\n", helpCommand, "No help available"; \
|
||||||
|
} \
|
||||||
|
} \
|
||||||
|
{ lastLine = $$0 }' $(MAKEFILE_LIST) | sort -u
|
||||||
|
@printf "\n"
|
||||||
@@ -6,9 +6,40 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from pkg_resources import get_distribution
|
|
||||||
|
|
||||||
__version__ = get_distribution('awx').version
|
def get_version():
|
||||||
|
version_from_file = get_version_from_file()
|
||||||
|
if version_from_file:
|
||||||
|
return version_from_file
|
||||||
|
else:
|
||||||
|
from setuptools_scm import get_version
|
||||||
|
|
||||||
|
version = get_version(root='..', relative_to=__file__)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def get_version_from_file():
|
||||||
|
vf = version_file()
|
||||||
|
if vf:
|
||||||
|
with open(vf, 'r') as file:
|
||||||
|
return file.read().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def version_file():
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
version_file = os.path.join(current_dir, '..', 'VERSION')
|
||||||
|
|
||||||
|
if os.path.exists(version_file):
|
||||||
|
return version_file
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
|
__version__ = pkg_resources.get_distribution('awx').version
|
||||||
|
except pkg_resources.DistributionNotFound:
|
||||||
|
__version__ = get_version()
|
||||||
|
|
||||||
__all__ = ['__version__']
|
__all__ = ['__version__']
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +52,6 @@ try:
|
|||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
MODE = 'production'
|
MODE = 'production'
|
||||||
|
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -78,9 +108,10 @@ def oauth2_getattribute(self, attr):
|
|||||||
# Custom method to override
|
# Custom method to override
|
||||||
# oauth2_provider.settings.OAuth2ProviderSettings.__getattribute__
|
# oauth2_provider.settings.OAuth2ProviderSettings.__getattribute__
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from oauth2_provider.settings import DEFAULTS
|
||||||
|
|
||||||
val = None
|
val = None
|
||||||
if 'migrate' not in sys.argv:
|
if (isinstance(attr, str)) and (attr in DEFAULTS) and (not attr.startswith('_')):
|
||||||
# certain Django OAuth Toolkit migrations actually reference
|
# certain Django OAuth Toolkit migrations actually reference
|
||||||
# setting lookups for references to model classes (e.g.,
|
# setting lookups for references to model classes (e.g.,
|
||||||
# oauth2_settings.REFRESH_TOKEN_MODEL)
|
# oauth2_settings.REFRESH_TOKEN_MODEL)
|
||||||
@@ -159,7 +190,7 @@ def manage():
|
|||||||
sys.stdout.write('%s\n' % __version__)
|
sys.stdout.write('%s\n' % __version__)
|
||||||
# If running as a user without permission to read settings, display an
|
# If running as a user without permission to read settings, display an
|
||||||
# error message. Allow --help to still work.
|
# error message. Allow --help to still work.
|
||||||
elif settings.SECRET_KEY == 'permission-denied':
|
elif not os.getenv('SKIP_SECRET_KEY_CHECK', False) and settings.SECRET_KEY == 'permission-denied':
|
||||||
if len(sys.argv) == 1 or len(sys.argv) >= 2 and sys.argv[1] in ('-h', '--help', 'help'):
|
if len(sys.argv) == 1 or len(sys.argv) >= 2 and sys.argv[1] in ('-h', '--help', 'help'):
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
sys.stdout.write('\n')
|
sys.stdout.write('\n')
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ class FieldLookupBackend(BaseFilterBackend):
|
|||||||
|
|
||||||
# A list of fields that we know can be filtered on without the possiblity
|
# A list of fields that we know can be filtered on without the possiblity
|
||||||
# of introducing duplicates
|
# of introducing duplicates
|
||||||
NO_DUPLICATES_ALLOW_LIST = (CharField, IntegerField, BooleanField)
|
NO_DUPLICATES_ALLOW_LIST = (CharField, IntegerField, BooleanField, TextField)
|
||||||
|
|
||||||
def get_fields_from_lookup(self, model, lookup):
|
def get_fields_from_lookup(self, model, lookup):
|
||||||
|
|
||||||
@@ -232,6 +232,9 @@ class FieldLookupBackend(BaseFilterBackend):
|
|||||||
re.compile(value)
|
re.compile(value)
|
||||||
except re.error as e:
|
except re.error as e:
|
||||||
raise ValueError(e.args[0])
|
raise ValueError(e.args[0])
|
||||||
|
elif new_lookup.endswith('__iexact'):
|
||||||
|
if not isinstance(field, (CharField, TextField)):
|
||||||
|
raise ValueError(f'{field.name} is not a text field and cannot be filtered by case-insensitive search')
|
||||||
elif new_lookup.endswith('__search'):
|
elif new_lookup.endswith('__search'):
|
||||||
related_model = getattr(field, 'related_model', None)
|
related_model = getattr(field, 'related_model', None)
|
||||||
if not related_model:
|
if not related_model:
|
||||||
@@ -258,8 +261,8 @@ class FieldLookupBackend(BaseFilterBackend):
|
|||||||
search_filters = {}
|
search_filters = {}
|
||||||
needs_distinct = False
|
needs_distinct = False
|
||||||
# Can only have two values: 'AND', 'OR'
|
# Can only have two values: 'AND', 'OR'
|
||||||
# If 'AND' is used, an iterm must satisfy all condition to show up in the results.
|
# If 'AND' is used, an item must satisfy all conditions to show up in the results.
|
||||||
# If 'OR' is used, an item just need to satisfy one condition to appear in results.
|
# If 'OR' is used, an item just needs to satisfy one condition to appear in results.
|
||||||
search_filter_relation = 'OR'
|
search_filter_relation = 'OR'
|
||||||
for key, values in request.query_params.lists():
|
for key, values in request.query_params.lists():
|
||||||
if key in self.RESERVED_NAMES:
|
if key in self.RESERVED_NAMES:
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
from django.utils.text import capfirst
|
from django.utils.text import capfirst
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.functional import cached_property
|
|
||||||
|
|
||||||
# Django REST Framework
|
# Django REST Framework
|
||||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||||
@@ -1607,7 +1606,6 @@ class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer):
|
|||||||
|
|
||||||
class ProjectUpdateDetailSerializer(ProjectUpdateSerializer):
|
class ProjectUpdateDetailSerializer(ProjectUpdateSerializer):
|
||||||
|
|
||||||
host_status_counts = serializers.SerializerMethodField(help_text=_('A count of hosts uniquely assigned to each status.'))
|
|
||||||
playbook_counts = serializers.SerializerMethodField(help_text=_('A count of all plays and tasks for the job run.'))
|
playbook_counts = serializers.SerializerMethodField(help_text=_('A count of all plays and tasks for the job run.'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -1622,14 +1620,6 @@ class ProjectUpdateDetailSerializer(ProjectUpdateSerializer):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_host_status_counts(self, obj):
|
|
||||||
try:
|
|
||||||
counts = obj.project_update_events.only('event_data').get(event='playbook_on_stats').get_host_status_counts()
|
|
||||||
except ProjectUpdateEvent.DoesNotExist:
|
|
||||||
counts = {}
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateListSerializer(ProjectUpdateSerializer, UnifiedJobListSerializer):
|
class ProjectUpdateListSerializer(ProjectUpdateSerializer, UnifiedJobListSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -2082,7 +2072,7 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventorySource
|
model = InventorySource
|
||||||
fields = ('*', 'name', 'inventory', 'update_on_launch', 'update_cache_timeout', 'source_project', 'update_on_project_update') + (
|
fields = ('*', 'name', 'inventory', 'update_on_launch', 'update_cache_timeout', 'source_project') + (
|
||||||
'last_update_failed',
|
'last_update_failed',
|
||||||
'last_updated',
|
'last_updated',
|
||||||
) # Backwards compatibility.
|
) # Backwards compatibility.
|
||||||
@@ -2145,11 +2135,6 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
|
|||||||
raise serializers.ValidationError(_("Cannot use manual project for SCM-based inventory."))
|
raise serializers.ValidationError(_("Cannot use manual project for SCM-based inventory."))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_update_on_project_update(self, value):
|
|
||||||
if value and self.instance and self.instance.schedules.exists():
|
|
||||||
raise serializers.ValidationError(_("Setting not compatible with existing schedules."))
|
|
||||||
return value
|
|
||||||
|
|
||||||
def validate_inventory(self, value):
|
def validate_inventory(self, value):
|
||||||
if value and value.kind == 'smart':
|
if value and value.kind == 'smart':
|
||||||
raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart Inventory")})
|
raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart Inventory")})
|
||||||
@@ -2200,7 +2185,7 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
|
|||||||
if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None:
|
if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None:
|
||||||
raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")})
|
raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")})
|
||||||
else:
|
else:
|
||||||
redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path', 'update_on_project_update']))
|
redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path']))
|
||||||
if redundant_scm_fields:
|
if redundant_scm_fields:
|
||||||
raise serializers.ValidationError({"detail": _("Cannot set %s if not SCM type." % ' '.join(redundant_scm_fields))})
|
raise serializers.ValidationError({"detail": _("Cannot set %s if not SCM type." % ' '.join(redundant_scm_fields))})
|
||||||
|
|
||||||
@@ -2245,7 +2230,6 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri
|
|||||||
'source_project_update',
|
'source_project_update',
|
||||||
'custom_virtualenv',
|
'custom_virtualenv',
|
||||||
'instance_group',
|
'instance_group',
|
||||||
'-controller_node',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_related(self, obj):
|
def get_related(self, obj):
|
||||||
@@ -2320,7 +2304,6 @@ class InventoryUpdateDetailSerializer(InventoryUpdateSerializer):
|
|||||||
class InventoryUpdateListSerializer(InventoryUpdateSerializer, UnifiedJobListSerializer):
|
class InventoryUpdateListSerializer(InventoryUpdateSerializer, UnifiedJobListSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryUpdate
|
model = InventoryUpdate
|
||||||
fields = ('*', '-controller_node') # field removal undone by UJ serializer
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryUpdateCancelSerializer(InventoryUpdateSerializer):
|
class InventoryUpdateCancelSerializer(InventoryUpdateSerializer):
|
||||||
@@ -2673,6 +2656,13 @@ class CredentialSerializer(BaseSerializer):
|
|||||||
|
|
||||||
return credential_type
|
return credential_type
|
||||||
|
|
||||||
|
def validate_inputs(self, inputs):
|
||||||
|
if self.instance and self.instance.credential_type.kind == "vault":
|
||||||
|
if 'vault_id' in inputs and inputs['vault_id'] != self.instance.inputs['vault_id']:
|
||||||
|
raise ValidationError(_('Vault IDs cannot be changed once they have been created.'))
|
||||||
|
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
class CredentialSerializerCreate(CredentialSerializer):
|
class CredentialSerializerCreate(CredentialSerializer):
|
||||||
|
|
||||||
@@ -3107,7 +3097,6 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
|
|||||||
|
|
||||||
class JobDetailSerializer(JobSerializer):
|
class JobDetailSerializer(JobSerializer):
|
||||||
|
|
||||||
host_status_counts = serializers.SerializerMethodField(help_text=_('A count of hosts uniquely assigned to each status.'))
|
|
||||||
playbook_counts = serializers.SerializerMethodField(help_text=_('A count of all plays and tasks for the job run.'))
|
playbook_counts = serializers.SerializerMethodField(help_text=_('A count of all plays and tasks for the job run.'))
|
||||||
custom_virtualenv = serializers.ReadOnlyField()
|
custom_virtualenv = serializers.ReadOnlyField()
|
||||||
|
|
||||||
@@ -3123,14 +3112,6 @@ class JobDetailSerializer(JobSerializer):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_host_status_counts(self, obj):
|
|
||||||
try:
|
|
||||||
counts = obj.get_event_queryset().only('event_data').get(event='playbook_on_stats').get_host_status_counts()
|
|
||||||
except JobEvent.DoesNotExist:
|
|
||||||
counts = {}
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
class JobCancelSerializer(BaseSerializer):
|
class JobCancelSerializer(BaseSerializer):
|
||||||
|
|
||||||
@@ -3319,21 +3300,10 @@ class AdHocCommandSerializer(UnifiedJobSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class AdHocCommandDetailSerializer(AdHocCommandSerializer):
|
class AdHocCommandDetailSerializer(AdHocCommandSerializer):
|
||||||
|
|
||||||
host_status_counts = serializers.SerializerMethodField(help_text=_('A count of hosts uniquely assigned to each status.'))
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AdHocCommand
|
model = AdHocCommand
|
||||||
fields = ('*', 'host_status_counts')
|
fields = ('*', 'host_status_counts')
|
||||||
|
|
||||||
def get_host_status_counts(self, obj):
|
|
||||||
try:
|
|
||||||
counts = obj.ad_hoc_command_events.only('event_data').get(event='playbook_on_stats').get_host_status_counts()
|
|
||||||
except AdHocCommandEvent.DoesNotExist:
|
|
||||||
counts = {}
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
class AdHocCommandCancelSerializer(AdHocCommandSerializer):
|
class AdHocCommandCancelSerializer(AdHocCommandSerializer):
|
||||||
|
|
||||||
@@ -4502,7 +4472,10 @@ class NotificationTemplateSerializer(BaseSerializer):
|
|||||||
body = messages[event].get('body', {})
|
body = messages[event].get('body', {})
|
||||||
if body:
|
if body:
|
||||||
try:
|
try:
|
||||||
potential_body = json.loads(body)
|
rendered_body = (
|
||||||
|
sandbox.ImmutableSandboxedEnvironment(undefined=DescriptiveUndefined).from_string(body).render(JobNotificationMixin.context_stub())
|
||||||
|
)
|
||||||
|
potential_body = json.loads(rendered_body)
|
||||||
if not isinstance(potential_body, dict):
|
if not isinstance(potential_body, dict):
|
||||||
error_list.append(
|
error_list.append(
|
||||||
_("Webhook body for '{}' should be a json dictionary. Found type '{}'.".format(event, type(potential_body).__name__))
|
_("Webhook body for '{}' should be a json dictionary. Found type '{}'.".format(event, type(potential_body).__name__))
|
||||||
@@ -4645,69 +4618,74 @@ class SchedulePreviewSerializer(BaseSerializer):
|
|||||||
|
|
||||||
# We reject rrules if:
|
# We reject rrules if:
|
||||||
# - DTSTART is not include
|
# - DTSTART is not include
|
||||||
# - INTERVAL is not included
|
# - Multiple DTSTART
|
||||||
# - SECONDLY is used
|
# - At least one of RRULE is not included
|
||||||
# - TZID is used
|
# - EXDATE or RDATE is included
|
||||||
# - BYDAY prefixed with a number (MO is good but not 20MO)
|
# For any rule in the ruleset:
|
||||||
# - BYYEARDAY
|
# - INTERVAL is not included
|
||||||
# - BYWEEKNO
|
# - SECONDLY is used
|
||||||
# - Multiple DTSTART or RRULE elements
|
# - BYDAY prefixed with a number (MO is good but not 20MO)
|
||||||
# - Can't contain both COUNT and UNTIL
|
# - Can't contain both COUNT and UNTIL
|
||||||
# - COUNT > 999
|
# - COUNT > 999
|
||||||
def validate_rrule(self, value):
|
def validate_rrule(self, value):
|
||||||
rrule_value = value
|
rrule_value = value
|
||||||
multi_by_month_day = r".*?BYMONTHDAY[\:\=][0-9]+,-*[0-9]+"
|
|
||||||
multi_by_month = r".*?BYMONTH[\:\=][0-9]+,[0-9]+"
|
|
||||||
by_day_with_numeric_prefix = r".*?BYDAY[\:\=][0-9]+[a-zA-Z]{2}"
|
by_day_with_numeric_prefix = r".*?BYDAY[\:\=][0-9]+[a-zA-Z]{2}"
|
||||||
match_count = re.match(r".*?(COUNT\=[0-9]+)", rrule_value)
|
|
||||||
match_multiple_dtstart = re.findall(r".*?(DTSTART(;[^:]+)?\:[0-9]+T[0-9]+Z?)", rrule_value)
|
match_multiple_dtstart = re.findall(r".*?(DTSTART(;[^:]+)?\:[0-9]+T[0-9]+Z?)", rrule_value)
|
||||||
match_native_dtstart = re.findall(r".*?(DTSTART:[0-9]+T[0-9]+) ", rrule_value)
|
match_native_dtstart = re.findall(r".*?(DTSTART:[0-9]+T[0-9]+) ", rrule_value)
|
||||||
match_multiple_rrule = re.findall(r".*?(RRULE\:)", rrule_value)
|
match_multiple_rrule = re.findall(r".*?(RULE\:[^\s]*)", rrule_value)
|
||||||
|
errors = []
|
||||||
if not len(match_multiple_dtstart):
|
if not len(match_multiple_dtstart):
|
||||||
raise serializers.ValidationError(_('Valid DTSTART required in rrule. Value should start with: DTSTART:YYYYMMDDTHHMMSSZ'))
|
errors.append(_('Valid DTSTART required in rrule. Value should start with: DTSTART:YYYYMMDDTHHMMSSZ'))
|
||||||
if len(match_native_dtstart):
|
if len(match_native_dtstart):
|
||||||
raise serializers.ValidationError(_('DTSTART cannot be a naive datetime. Specify ;TZINFO= or YYYYMMDDTHHMMSSZZ.'))
|
errors.append(_('DTSTART cannot be a naive datetime. Specify ;TZINFO= or YYYYMMDDTHHMMSSZZ.'))
|
||||||
if len(match_multiple_dtstart) > 1:
|
if len(match_multiple_dtstart) > 1:
|
||||||
raise serializers.ValidationError(_('Multiple DTSTART is not supported.'))
|
errors.append(_('Multiple DTSTART is not supported.'))
|
||||||
if not len(match_multiple_rrule):
|
if "rrule:" not in rrule_value.lower():
|
||||||
raise serializers.ValidationError(_('RRULE required in rrule.'))
|
errors.append(_('One or more rule required in rrule.'))
|
||||||
if len(match_multiple_rrule) > 1:
|
if "exdate:" in rrule_value.lower():
|
||||||
raise serializers.ValidationError(_('Multiple RRULE is not supported.'))
|
raise serializers.ValidationError(_('EXDATE not allowed in rrule.'))
|
||||||
if 'interval' not in rrule_value.lower():
|
if "rdate:" in rrule_value.lower():
|
||||||
raise serializers.ValidationError(_('INTERVAL required in rrule.'))
|
raise serializers.ValidationError(_('RDATE not allowed in rrule.'))
|
||||||
if 'secondly' in rrule_value.lower():
|
for a_rule in match_multiple_rrule:
|
||||||
raise serializers.ValidationError(_('SECONDLY is not supported.'))
|
if 'interval' not in a_rule.lower():
|
||||||
if re.match(multi_by_month_day, rrule_value):
|
errors.append("{0}: {1}".format(_('INTERVAL required in rrule'), a_rule))
|
||||||
raise serializers.ValidationError(_('Multiple BYMONTHDAYs not supported.'))
|
elif 'secondly' in a_rule.lower():
|
||||||
if re.match(multi_by_month, rrule_value):
|
errors.append("{0}: {1}".format(_('SECONDLY is not supported'), a_rule))
|
||||||
raise serializers.ValidationError(_('Multiple BYMONTHs not supported.'))
|
if re.match(by_day_with_numeric_prefix, a_rule):
|
||||||
if re.match(by_day_with_numeric_prefix, rrule_value):
|
errors.append("{0}: {1}".format(_("BYDAY with numeric prefix not supported"), a_rule))
|
||||||
raise serializers.ValidationError(_("BYDAY with numeric prefix not supported."))
|
if 'COUNT' in a_rule and 'UNTIL' in a_rule:
|
||||||
if 'byyearday' in rrule_value.lower():
|
errors.append("{0}: {1}".format(_("RRULE may not contain both COUNT and UNTIL"), a_rule))
|
||||||
raise serializers.ValidationError(_("BYYEARDAY not supported."))
|
match_count = re.match(r".*?(COUNT\=[0-9]+)", a_rule)
|
||||||
if 'byweekno' in rrule_value.lower():
|
if match_count:
|
||||||
raise serializers.ValidationError(_("BYWEEKNO not supported."))
|
count_val = match_count.groups()[0].strip().split("=")
|
||||||
if 'COUNT' in rrule_value and 'UNTIL' in rrule_value:
|
if int(count_val[1]) > 999:
|
||||||
raise serializers.ValidationError(_("RRULE may not contain both COUNT and UNTIL"))
|
errors.append("{0}: {1}".format(_("COUNT > 999 is unsupported"), a_rule))
|
||||||
if match_count:
|
|
||||||
count_val = match_count.groups()[0].strip().split("=")
|
|
||||||
if int(count_val[1]) > 999:
|
|
||||||
raise serializers.ValidationError(_("COUNT > 999 is unsupported."))
|
|
||||||
try:
|
try:
|
||||||
Schedule.rrulestr(rrule_value)
|
Schedule.rrulestr(rrule_value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
raise serializers.ValidationError(_("rrule parsing failed validation: {}").format(e))
|
errors.append(_("rrule parsing failed validation: {}").format(e))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise serializers.ValidationError(errors)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSerializer):
|
class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSerializer):
|
||||||
show_capabilities = ['edit', 'delete']
|
show_capabilities = ['edit', 'delete']
|
||||||
|
|
||||||
timezone = serializers.SerializerMethodField()
|
timezone = serializers.SerializerMethodField(
|
||||||
until = serializers.SerializerMethodField()
|
help_text=_(
|
||||||
|
'The timezone this schedule runs in. This field is extracted from the RRULE. If the timezone in the RRULE is a link to another timezone, the link will be reflected in this field.'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
until = serializers.SerializerMethodField(
|
||||||
|
help_text=_('The date this schedule will end. This field is computed from the RRULE. If the schedule does not end an emptry string will be returned'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Schedule
|
model = Schedule
|
||||||
@@ -4761,13 +4739,6 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
|
|||||||
raise serializers.ValidationError(_('Inventory Source must be a cloud resource.'))
|
raise serializers.ValidationError(_('Inventory Source must be a cloud resource.'))
|
||||||
elif type(value) == Project and value.scm_type == '':
|
elif type(value) == Project and value.scm_type == '':
|
||||||
raise serializers.ValidationError(_('Manual Project cannot have a schedule set.'))
|
raise serializers.ValidationError(_('Manual Project cannot have a schedule set.'))
|
||||||
elif type(value) == InventorySource and value.source == 'scm' and value.update_on_project_update:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
_(
|
|
||||||
'Inventory sources with `update_on_project_update` cannot be scheduled. '
|
|
||||||
'Schedule its source project `{}` instead.'.format(value.source_project.name)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
@@ -5036,8 +5007,7 @@ class ActivityStreamSerializer(BaseSerializer):
|
|||||||
object_association = serializers.SerializerMethodField(help_text=_("When present, shows the field name of the role or relationship that changed."))
|
object_association = serializers.SerializerMethodField(help_text=_("When present, shows the field name of the role or relationship that changed."))
|
||||||
object_type = serializers.SerializerMethodField(help_text=_("When present, shows the model on which the role or relationship was defined."))
|
object_type = serializers.SerializerMethodField(help_text=_("When present, shows the model on which the role or relationship was defined."))
|
||||||
|
|
||||||
@cached_property
|
def _local_summarizable_fk_fields(self, obj):
|
||||||
def _local_summarizable_fk_fields(self):
|
|
||||||
summary_dict = copy.copy(SUMMARIZABLE_FK_FIELDS)
|
summary_dict = copy.copy(SUMMARIZABLE_FK_FIELDS)
|
||||||
# Special requests
|
# Special requests
|
||||||
summary_dict['group'] = summary_dict['group'] + ('inventory_id',)
|
summary_dict['group'] = summary_dict['group'] + ('inventory_id',)
|
||||||
@@ -5057,7 +5027,13 @@ class ActivityStreamSerializer(BaseSerializer):
|
|||||||
('workflow_approval', ('id', 'name', 'unified_job_id')),
|
('workflow_approval', ('id', 'name', 'unified_job_id')),
|
||||||
('instance', ('id', 'hostname')),
|
('instance', ('id', 'hostname')),
|
||||||
]
|
]
|
||||||
return field_list
|
# Optimization - do not attempt to summarize all fields, pair down to only relations that exist
|
||||||
|
if not obj:
|
||||||
|
return field_list
|
||||||
|
existing_association_types = [obj.object1, obj.object2]
|
||||||
|
if 'user' in existing_association_types:
|
||||||
|
existing_association_types.append('role')
|
||||||
|
return [entry for entry in field_list if entry[0] in existing_association_types]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ActivityStream
|
model = ActivityStream
|
||||||
@@ -5141,7 +5117,7 @@ class ActivityStreamSerializer(BaseSerializer):
|
|||||||
data = {}
|
data = {}
|
||||||
if obj.actor is not None:
|
if obj.actor is not None:
|
||||||
data['actor'] = self.reverse('api:user_detail', kwargs={'pk': obj.actor.pk})
|
data['actor'] = self.reverse('api:user_detail', kwargs={'pk': obj.actor.pk})
|
||||||
for fk, __ in self._local_summarizable_fk_fields:
|
for fk, __ in self._local_summarizable_fk_fields(obj):
|
||||||
if not hasattr(obj, fk):
|
if not hasattr(obj, fk):
|
||||||
continue
|
continue
|
||||||
m2m_list = self._get_related_objects(obj, fk)
|
m2m_list = self._get_related_objects(obj, fk)
|
||||||
@@ -5198,7 +5174,7 @@ class ActivityStreamSerializer(BaseSerializer):
|
|||||||
|
|
||||||
def get_summary_fields(self, obj):
|
def get_summary_fields(self, obj):
|
||||||
summary_fields = OrderedDict()
|
summary_fields = OrderedDict()
|
||||||
for fk, related_fields in self._local_summarizable_fk_fields:
|
for fk, related_fields in self._local_summarizable_fk_fields(obj):
|
||||||
try:
|
try:
|
||||||
if not hasattr(obj, fk):
|
if not hasattr(obj, fk):
|
||||||
continue
|
continue
|
||||||
|
|||||||
17
awx/api/urls/debug.py
Normal file
17
awx/api/urls/debug.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from awx.api.views.debug import (
|
||||||
|
DebugRootView,
|
||||||
|
TaskManagerDebugView,
|
||||||
|
DependencyManagerDebugView,
|
||||||
|
WorkflowManagerDebugView,
|
||||||
|
)
|
||||||
|
|
||||||
|
urls = [
|
||||||
|
re_path(r'^$', DebugRootView.as_view(), name='debug'),
|
||||||
|
re_path(r'^task_manager/$', TaskManagerDebugView.as_view(), name='task_manager'),
|
||||||
|
re_path(r'^dependency_manager/$', DependencyManagerDebugView.as_view(), name='dependency_manager'),
|
||||||
|
re_path(r'^workflow_manager/$', WorkflowManagerDebugView.as_view(), name='workflow_manager'),
|
||||||
|
]
|
||||||
|
|
||||||
|
__all__ = ['urls']
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
# All Rights Reserved.
|
# All Rights Reserved.
|
||||||
|
|
||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
from django.conf import settings
|
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
|
from awx import MODE
|
||||||
from awx.api.generics import LoggedLoginView, LoggedLogoutView
|
from awx.api.generics import LoggedLoginView, LoggedLogoutView
|
||||||
from awx.api.views import (
|
from awx.api.views import (
|
||||||
ApiRootView,
|
ApiRootView,
|
||||||
@@ -145,7 +145,12 @@ urlpatterns = [
|
|||||||
re_path(r'^logout/$', LoggedLogoutView.as_view(next_page='/api/', redirect_field_name='next'), name='logout'),
|
re_path(r'^logout/$', LoggedLogoutView.as_view(next_page='/api/', redirect_field_name='next'), name='logout'),
|
||||||
re_path(r'^o/', include(oauth2_root_urls)),
|
re_path(r'^o/', include(oauth2_root_urls)),
|
||||||
]
|
]
|
||||||
if settings.SETTINGS_MODULE == 'awx.settings.development':
|
if MODE == 'development':
|
||||||
|
# Only include these if we are in the development environment
|
||||||
from awx.api.swagger import SwaggerSchemaView
|
from awx.api.swagger import SwaggerSchemaView
|
||||||
|
|
||||||
urlpatterns += [re_path(r'^swagger/$', SwaggerSchemaView.as_view(), name='swagger_view')]
|
urlpatterns += [re_path(r'^swagger/$', SwaggerSchemaView.as_view(), name='swagger_view')]
|
||||||
|
|
||||||
|
from awx.api.urls.debug import urls as debug_urls
|
||||||
|
|
||||||
|
urlpatterns += [re_path(r'^debug/', include(debug_urls))]
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ from awx.main.utils import (
|
|||||||
get_object_or_400,
|
get_object_or_400,
|
||||||
getattrd,
|
getattrd,
|
||||||
get_pk_from_dict,
|
get_pk_from_dict,
|
||||||
schedule_task_manager,
|
ScheduleWorkflowManager,
|
||||||
ignore_inventory_computed_fields,
|
ignore_inventory_computed_fields,
|
||||||
)
|
)
|
||||||
from awx.main.utils.encryption import encrypt_value
|
from awx.main.utils.encryption import encrypt_value
|
||||||
@@ -115,7 +115,6 @@ from awx.api.metadata import RoleMetadata
|
|||||||
from awx.main.constants import ACTIVE_STATES, SURVEY_TYPE_MAPPING
|
from awx.main.constants import ACTIVE_STATES, SURVEY_TYPE_MAPPING
|
||||||
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
||||||
from awx.api.views.mixin import (
|
from awx.api.views.mixin import (
|
||||||
ControlledByScmMixin,
|
|
||||||
InstanceGroupMembershipMixin,
|
InstanceGroupMembershipMixin,
|
||||||
OrganizationCountsMixin,
|
OrganizationCountsMixin,
|
||||||
RelatedJobsPreventDeleteMixin,
|
RelatedJobsPreventDeleteMixin,
|
||||||
@@ -537,6 +536,7 @@ class ScheduleList(ListCreateAPIView):
|
|||||||
name = _("Schedules")
|
name = _("Schedules")
|
||||||
model = models.Schedule
|
model = models.Schedule
|
||||||
serializer_class = serializers.ScheduleSerializer
|
serializer_class = serializers.ScheduleSerializer
|
||||||
|
ordering = ('id',)
|
||||||
|
|
||||||
|
|
||||||
class ScheduleDetail(RetrieveUpdateDestroyAPIView):
|
class ScheduleDetail(RetrieveUpdateDestroyAPIView):
|
||||||
@@ -577,8 +577,7 @@ class ScheduleZoneInfo(APIView):
|
|||||||
swagger_topic = 'System Configuration'
|
swagger_topic = 'System Configuration'
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
zones = [{'name': zone} for zone in models.Schedule.get_zoneinfo()]
|
return Response({'zones': models.Schedule.get_zoneinfo(), 'links': models.Schedule.get_zoneinfo_links()})
|
||||||
return Response(zones)
|
|
||||||
|
|
||||||
|
|
||||||
class LaunchConfigCredentialsBase(SubListAttachDetachAPIView):
|
class LaunchConfigCredentialsBase(SubListAttachDetachAPIView):
|
||||||
@@ -1675,7 +1674,7 @@ class HostList(HostRelatedSearchMixin, ListCreateAPIView):
|
|||||||
return Response(dict(error=_(str(e))), status=status.HTTP_400_BAD_REQUEST)
|
return Response(dict(error=_(str(e))), status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
class HostDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
class HostDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
always_allow_superuser = False
|
always_allow_superuser = False
|
||||||
model = models.Host
|
model = models.Host
|
||||||
@@ -1709,7 +1708,7 @@ class InventoryHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIVie
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class HostGroupsList(ControlledByScmMixin, SubListCreateAttachDetachAPIView):
|
class HostGroupsList(SubListCreateAttachDetachAPIView):
|
||||||
'''the list of groups a host is directly a member of'''
|
'''the list of groups a host is directly a member of'''
|
||||||
|
|
||||||
model = models.Group
|
model = models.Group
|
||||||
@@ -1825,7 +1824,7 @@ class EnforceParentRelationshipMixin(object):
|
|||||||
return super(EnforceParentRelationshipMixin, self).create(request, *args, **kwargs)
|
return super(EnforceParentRelationshipMixin, self).create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class GroupChildrenList(ControlledByScmMixin, EnforceParentRelationshipMixin, SubListCreateAttachDetachAPIView):
|
class GroupChildrenList(EnforceParentRelationshipMixin, SubListCreateAttachDetachAPIView):
|
||||||
|
|
||||||
model = models.Group
|
model = models.Group
|
||||||
serializer_class = serializers.GroupSerializer
|
serializer_class = serializers.GroupSerializer
|
||||||
@@ -1871,7 +1870,7 @@ class GroupPotentialChildrenList(SubListAPIView):
|
|||||||
return qs.exclude(pk__in=except_pks)
|
return qs.exclude(pk__in=except_pks)
|
||||||
|
|
||||||
|
|
||||||
class GroupHostsList(HostRelatedSearchMixin, ControlledByScmMixin, SubListCreateAttachDetachAPIView):
|
class GroupHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIView):
|
||||||
'''the list of hosts directly below a group'''
|
'''the list of hosts directly below a group'''
|
||||||
|
|
||||||
model = models.Host
|
model = models.Host
|
||||||
@@ -1935,7 +1934,7 @@ class GroupActivityStreamList(SubListAPIView):
|
|||||||
return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all()))
|
return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all()))
|
||||||
|
|
||||||
|
|
||||||
class GroupDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
class GroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = models.Group
|
model = models.Group
|
||||||
serializer_class = serializers.GroupSerializer
|
serializer_class = serializers.GroupSerializer
|
||||||
@@ -3392,7 +3391,7 @@ class WorkflowJobCancel(RetrieveAPIView):
|
|||||||
obj = self.get_object()
|
obj = self.get_object()
|
||||||
if obj.can_cancel:
|
if obj.can_cancel:
|
||||||
obj.cancel()
|
obj.cancel()
|
||||||
schedule_task_manager()
|
ScheduleWorkflowManager().schedule()
|
||||||
return Response(status=status.HTTP_202_ACCEPTED)
|
return Response(status=status.HTTP_202_ACCEPTED)
|
||||||
else:
|
else:
|
||||||
return self.http_method_not_allowed(request, *args, **kwargs)
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||||
@@ -3840,7 +3839,7 @@ class JobJobEventsList(BaseJobEventsList):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
job = self.get_parent_object()
|
job = self.get_parent_object()
|
||||||
self.check_parent_access(job)
|
self.check_parent_access(job)
|
||||||
return job.get_event_queryset().select_related('host').order_by('start_line')
|
return job.get_event_queryset().prefetch_related('job__job_template', 'host').order_by('start_line')
|
||||||
|
|
||||||
|
|
||||||
class JobJobEventsChildrenSummary(APIView):
|
class JobJobEventsChildrenSummary(APIView):
|
||||||
@@ -3849,7 +3848,7 @@ class JobJobEventsChildrenSummary(APIView):
|
|||||||
meta_events = ('debug', 'verbose', 'warning', 'error', 'system_warning', 'deprecated')
|
meta_events = ('debug', 'verbose', 'warning', 'error', 'system_warning', 'deprecated')
|
||||||
|
|
||||||
def get(self, request, **kwargs):
|
def get(self, request, **kwargs):
|
||||||
resp = dict(children_summary={}, meta_event_nested_uuid={}, event_processing_finished=False)
|
resp = dict(children_summary={}, meta_event_nested_uuid={}, event_processing_finished=False, is_tree=True)
|
||||||
job = get_object_or_404(models.Job, pk=kwargs['pk'])
|
job = get_object_or_404(models.Job, pk=kwargs['pk'])
|
||||||
if not job.event_processing_finished:
|
if not job.event_processing_finished:
|
||||||
return Response(resp)
|
return Response(resp)
|
||||||
@@ -3869,13 +3868,41 @@ class JobJobEventsChildrenSummary(APIView):
|
|||||||
# key is counter of meta events (i.e. verbose), value is uuid of the assigned parent
|
# key is counter of meta events (i.e. verbose), value is uuid of the assigned parent
|
||||||
map_meta_counter_nested_uuid = {}
|
map_meta_counter_nested_uuid = {}
|
||||||
|
|
||||||
|
# collapsable tree view in the UI only makes sense for tree-like
|
||||||
|
# hierarchy. If ansible is ran with a strategy like free or host_pinned, then
|
||||||
|
# events can be out of sequential order, and no longer follow a tree structure
|
||||||
|
# E1
|
||||||
|
# E2
|
||||||
|
# E3
|
||||||
|
# E4 <- parent is E3
|
||||||
|
# E5 <- parent is E1
|
||||||
|
# in the above, there is no clear way to collapse E1, because E5 comes after
|
||||||
|
# E3, which occurs after E1. Thus the tree view should be disabled.
|
||||||
|
|
||||||
|
# mark the last seen uuid at a given level (0-3)
|
||||||
|
# if a parent uuid is not in this list, then we know the events are not tree-like
|
||||||
|
# and return a response with is_tree: False
|
||||||
|
level_current_uuid = [None, None, None, None]
|
||||||
|
|
||||||
prev_non_meta_event = events[0]
|
prev_non_meta_event = events[0]
|
||||||
for i, e in enumerate(events):
|
for i, e in enumerate(events):
|
||||||
if not e['event'] in JobJobEventsChildrenSummary.meta_events:
|
if not e['event'] in JobJobEventsChildrenSummary.meta_events:
|
||||||
prev_non_meta_event = e
|
prev_non_meta_event = e
|
||||||
if not e['uuid']:
|
if not e['uuid']:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not e['event'] in JobJobEventsChildrenSummary.meta_events:
|
||||||
|
level = models.JobEvent.LEVEL_FOR_EVENT[e['event']]
|
||||||
|
level_current_uuid[level] = e['uuid']
|
||||||
|
# if setting level 1, for example, set levels 2 and 3 back to None
|
||||||
|
for u in range(level + 1, len(level_current_uuid)):
|
||||||
|
level_current_uuid[u] = None
|
||||||
|
|
||||||
puuid = e['parent_uuid']
|
puuid = e['parent_uuid']
|
||||||
|
if puuid and puuid not in level_current_uuid:
|
||||||
|
# improper tree detected, so bail out early
|
||||||
|
resp['is_tree'] = False
|
||||||
|
return Response(resp)
|
||||||
|
|
||||||
# if event is verbose (or debug, etc), we need to "assign" it a
|
# if event is verbose (or debug, etc), we need to "assign" it a
|
||||||
# parent. This code looks at the event level of the previous
|
# parent. This code looks at the event level of the previous
|
||||||
|
|||||||
68
awx/api/views/debug.py
Normal file
68
awx/api/views/debug.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from awx.api.generics import APIView
|
||||||
|
|
||||||
|
from awx.main.scheduler import TaskManager, DependencyManager, WorkflowManager
|
||||||
|
|
||||||
|
|
||||||
|
class TaskManagerDebugView(APIView):
|
||||||
|
_ignore_model_permissions = True
|
||||||
|
exclude_from_schema = True
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
prefix = 'Task'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
TaskManager().schedule()
|
||||||
|
if not settings.AWX_DISABLE_TASK_MANAGERS:
|
||||||
|
msg = f"Running {self.prefix} manager. To disable other triggers to the {self.prefix} manager, set AWX_DISABLE_TASK_MANAGERS to True"
|
||||||
|
else:
|
||||||
|
msg = f"AWX_DISABLE_TASK_MANAGERS is True, this view is the only way to trigger the {self.prefix} manager"
|
||||||
|
return Response(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyManagerDebugView(APIView):
|
||||||
|
_ignore_model_permissions = True
|
||||||
|
exclude_from_schema = True
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
prefix = 'Dependency'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
DependencyManager().schedule()
|
||||||
|
if not settings.AWX_DISABLE_TASK_MANAGERS:
|
||||||
|
msg = f"Running {self.prefix} manager. To disable other triggers to the {self.prefix} manager, set AWX_DISABLE_TASK_MANAGERS to True"
|
||||||
|
else:
|
||||||
|
msg = f"AWX_DISABLE_TASK_MANAGERS is True, this view is the only way to trigger the {self.prefix} manager"
|
||||||
|
return Response(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowManagerDebugView(APIView):
|
||||||
|
_ignore_model_permissions = True
|
||||||
|
exclude_from_schema = True
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
prefix = 'Workflow'
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
WorkflowManager().schedule()
|
||||||
|
if not settings.AWX_DISABLE_TASK_MANAGERS:
|
||||||
|
msg = f"Running {self.prefix} manager. To disable other triggers to the {self.prefix} manager, set AWX_DISABLE_TASK_MANAGERS to True"
|
||||||
|
else:
|
||||||
|
msg = f"AWX_DISABLE_TASK_MANAGERS is True, this view is the only way to trigger the {self.prefix} manager"
|
||||||
|
return Response(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class DebugRootView(APIView):
|
||||||
|
_ignore_model_permissions = True
|
||||||
|
exclude_from_schema = True
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, format=None):
|
||||||
|
'''List of available debug urls'''
|
||||||
|
data = OrderedDict()
|
||||||
|
data['task_manager'] = '/api/debug/task_manager/'
|
||||||
|
data['dependency_manager'] = '/api/debug/dependency_manager/'
|
||||||
|
data['workflow_manager'] = '/api/debug/workflow_manager/'
|
||||||
|
return Response(data)
|
||||||
@@ -41,7 +41,7 @@ from awx.api.serializers import (
|
|||||||
JobTemplateSerializer,
|
JobTemplateSerializer,
|
||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
)
|
)
|
||||||
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, ControlledByScmMixin
|
from awx.api.views.mixin import RelatedJobsPreventDeleteMixin
|
||||||
|
|
||||||
from awx.api.pagination import UnifiedJobEventPagination
|
from awx.api.pagination import UnifiedJobEventPagination
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ class InventoryList(ListCreateAPIView):
|
|||||||
serializer_class = InventorySerializer
|
serializer_class = InventorySerializer
|
||||||
|
|
||||||
|
|
||||||
class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = Inventory
|
model = Inventory
|
||||||
serializer_class = InventorySerializer
|
serializer_class = InventorySerializer
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ from django.shortcuts import get_object_or_404
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from rest_framework.permissions import SAFE_METHODS
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from awx.main.constants import ACTIVE_STATES
|
from awx.main.constants import ACTIVE_STATES
|
||||||
from awx.main.utils import get_object_or_400, parse_yaml_or_json
|
from awx.main.utils import get_object_or_400
|
||||||
from awx.main.models.ha import Instance, InstanceGroup
|
from awx.main.models.ha import Instance, InstanceGroup
|
||||||
from awx.main.models.organization import Team
|
from awx.main.models.organization import Team
|
||||||
from awx.main.models.projects import Project
|
from awx.main.models.projects import Project
|
||||||
@@ -186,35 +185,6 @@ class OrganizationCountsMixin(object):
|
|||||||
return full_context
|
return full_context
|
||||||
|
|
||||||
|
|
||||||
class ControlledByScmMixin(object):
|
|
||||||
"""
|
|
||||||
Special method to reset SCM inventory commit hash
|
|
||||||
if anything that it manages changes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _reset_inv_src_rev(self, obj):
|
|
||||||
if self.request.method in SAFE_METHODS or not obj:
|
|
||||||
return
|
|
||||||
project_following_sources = obj.inventory_sources.filter(update_on_project_update=True, source='scm')
|
|
||||||
if project_following_sources:
|
|
||||||
# Allow inventory changes unrelated to variables
|
|
||||||
if self.model == Inventory and (
|
|
||||||
not self.request or not self.request.data or parse_yaml_or_json(self.request.data.get('variables', '')) == parse_yaml_or_json(obj.variables)
|
|
||||||
):
|
|
||||||
return
|
|
||||||
project_following_sources.update(scm_last_revision='')
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
obj = super(ControlledByScmMixin, self).get_object()
|
|
||||||
self._reset_inv_src_rev(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def get_parent_object(self):
|
|
||||||
obj = super(ControlledByScmMixin, self).get_parent_object()
|
|
||||||
self._reset_inv_src_rev(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
class NoTruncateMixin(object):
|
class NoTruncateMixin(object):
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class GitlabWebhookReceiver(WebhookReceiverBase):
|
|||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
def get_event_status_api(self):
|
def get_event_status_api(self):
|
||||||
if self.get_event_type() != 'Merge Request Hook':
|
if self.get_event_type() not in self.ref_keys.keys():
|
||||||
return
|
return
|
||||||
project = self.request.data.get('project', {})
|
project = self.request.data.get('project', {})
|
||||||
repo_url = project.get('web_url')
|
repo_url = project.get('web_url')
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Python
|
# Python
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
@@ -31,7 +30,7 @@ from awx.conf.models import Setting
|
|||||||
|
|
||||||
logger = logging.getLogger('awx.conf.settings')
|
logger = logging.getLogger('awx.conf.settings')
|
||||||
|
|
||||||
SETTING_MEMORY_TTL = 5 if 'callback_receiver' in ' '.join(sys.argv) else 0
|
SETTING_MEMORY_TTL = 5
|
||||||
|
|
||||||
# Store a special value to indicate when a setting is not set in the database.
|
# Store a special value to indicate when a setting is not set in the database.
|
||||||
SETTING_CACHE_NOTSET = '___notset___'
|
SETTING_CACHE_NOTSET = '___notset___'
|
||||||
@@ -81,7 +80,7 @@ def _ctit_db_wrapper(trans_safe=False):
|
|||||||
yield
|
yield
|
||||||
except DBError as exc:
|
except DBError as exc:
|
||||||
if trans_safe:
|
if trans_safe:
|
||||||
level = logger.exception
|
level = logger.warning
|
||||||
if isinstance(exc, ProgrammingError):
|
if isinstance(exc, ProgrammingError):
|
||||||
if 'relation' in str(exc) and 'does not exist' in str(exc):
|
if 'relation' in str(exc) and 'does not exist' in str(exc):
|
||||||
# this generally means we can't fetch Tower configuration
|
# this generally means we can't fetch Tower configuration
|
||||||
@@ -90,7 +89,7 @@ def _ctit_db_wrapper(trans_safe=False):
|
|||||||
# has come up *before* the database has finished migrating, and
|
# has come up *before* the database has finished migrating, and
|
||||||
# especially that the conf.settings table doesn't exist yet
|
# especially that the conf.settings table doesn't exist yet
|
||||||
level = logger.debug
|
level = logger.debug
|
||||||
level('Database settings are not available, using defaults.')
|
level(f'Database settings are not available, using defaults. error: {str(exc)}')
|
||||||
else:
|
else:
|
||||||
logger.exception('Error modifying something related to database settings.')
|
logger.exception('Error modifying something related to database settings.')
|
||||||
finally:
|
finally:
|
||||||
@@ -234,6 +233,8 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
self.__dict__['_awx_conf_init_readonly'] = False
|
self.__dict__['_awx_conf_init_readonly'] = False
|
||||||
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
|
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
|
||||||
self.__dict__['registry'] = registry
|
self.__dict__['registry'] = registry
|
||||||
|
self.__dict__['_awx_conf_memoizedcache'] = cachetools.TTLCache(maxsize=2048, ttl=SETTING_MEMORY_TTL)
|
||||||
|
self.__dict__['_awx_conf_memoizedcache_lock'] = threading.Lock()
|
||||||
|
|
||||||
# record the current pid so we compare it post-fork for
|
# record the current pid so we compare it post-fork for
|
||||||
# processes like the dispatcher and callback receiver
|
# processes like the dispatcher and callback receiver
|
||||||
@@ -396,12 +397,20 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
def SETTINGS_MODULE(self):
|
def SETTINGS_MODULE(self):
|
||||||
return self._get_default('SETTINGS_MODULE')
|
return self._get_default('SETTINGS_MODULE')
|
||||||
|
|
||||||
@cachetools.cached(cache=cachetools.TTLCache(maxsize=2048, ttl=SETTING_MEMORY_TTL))
|
@cachetools.cachedmethod(
|
||||||
|
cache=lambda self: self.__dict__['_awx_conf_memoizedcache'],
|
||||||
|
key=lambda *args, **kwargs: SettingsWrapper.hashkey(*args, **kwargs),
|
||||||
|
lock=lambda self: self.__dict__['_awx_conf_memoizedcache_lock'],
|
||||||
|
)
|
||||||
|
def _get_local_with_cache(self, name):
|
||||||
|
"""Get value while accepting the in-memory cache if key is available"""
|
||||||
|
with _ctit_db_wrapper(trans_safe=True):
|
||||||
|
return self._get_local(name)
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
value = empty
|
value = empty
|
||||||
if name in self.all_supported_settings:
|
if name in self.all_supported_settings:
|
||||||
with _ctit_db_wrapper(trans_safe=True):
|
value = self._get_local_with_cache(name)
|
||||||
value = self._get_local(name)
|
|
||||||
if value is not empty:
|
if value is not empty:
|
||||||
return value
|
return value
|
||||||
return self._get_default(name)
|
return self._get_default(name)
|
||||||
@@ -475,6 +484,23 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
set_on_default = getattr(self.default_settings, 'is_overridden', lambda s: False)(setting)
|
set_on_default = getattr(self.default_settings, 'is_overridden', lambda s: False)(setting)
|
||||||
return set_locally or set_on_default
|
return set_locally or set_on_default
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def hashkey(cls, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Usage of @cachetools.cached has changed to @cachetools.cachedmethod
|
||||||
|
The previous cachetools decorator called the hash function and passed in (self, key).
|
||||||
|
The new cachtools decorator calls the hash function with just (key).
|
||||||
|
Ideally, we would continue to pass self, however, the cachetools decorator interface
|
||||||
|
does not allow us to.
|
||||||
|
|
||||||
|
This hashkey function is to maintain that the key generated looks like
|
||||||
|
('<SettingsWrapper>', key). The thought is that maybe it is important to namespace
|
||||||
|
our cache to the SettingsWrapper scope in case some other usage of this cache exists.
|
||||||
|
I can not think of how any other system could and would use our private cache, but
|
||||||
|
for safety sake we are ensuring the key schema does not change.
|
||||||
|
"""
|
||||||
|
return cachetools.keys.hashkey(f"<{cls.__name__}>", *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def __getattr_without_cache__(self, name):
|
def __getattr_without_cache__(self, name):
|
||||||
# Django 1.10 added an optimization to settings lookup:
|
# Django 1.10 added an optimization to settings lookup:
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ def handle_setting_change(key, for_delete=False):
|
|||||||
cache_keys = {Setting.get_cache_key(k) for k in setting_keys}
|
cache_keys = {Setting.get_cache_key(k) for k in setting_keys}
|
||||||
cache.delete_many(cache_keys)
|
cache.delete_many(cache_keys)
|
||||||
|
|
||||||
|
# if we have changed a setting, we want to avoid mucking with the in-memory cache entirely
|
||||||
|
settings._awx_conf_memoizedcache.clear()
|
||||||
|
|
||||||
# Send setting_changed signal with new value for each setting.
|
# Send setting_changed signal with new value for each setting.
|
||||||
for setting_key in setting_keys:
|
for setting_key in setting_keys:
|
||||||
setting_changed.send(sender=Setting, setting=setting_key, value=getattr(settings, setting_key, None), enter=not bool(for_delete))
|
setting_changed.send(sender=Setting, setting=setting_key, value=getattr(settings, setting_key, None), enter=not bool(for_delete))
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import codecs
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import LazySettings
|
from django.conf import LazySettings
|
||||||
from django.core.cache.backends.locmem import LocMemCache
|
from django.core.cache.backends.locmem import LocMemCache
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
@@ -299,3 +301,33 @@ def test_readonly_sensitive_cache_data_is_encrypted(settings):
|
|||||||
cache.set('AWX_ENCRYPTED', 'SECRET!')
|
cache.set('AWX_ENCRYPTED', 'SECRET!')
|
||||||
assert cache.get('AWX_ENCRYPTED') == 'SECRET!'
|
assert cache.get('AWX_ENCRYPTED') == 'SECRET!'
|
||||||
assert native_cache.get('AWX_ENCRYPTED') == 'FRPERG!'
|
assert native_cache.get('AWX_ENCRYPTED') == 'FRPERG!'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.defined_in_file(AWX_VAR='DEFAULT')
|
||||||
|
def test_in_memory_cache_only_for_registered_settings(settings):
|
||||||
|
"Test that we only make use of the in-memory TTL cache for registered settings"
|
||||||
|
settings._awx_conf_memoizedcache.clear()
|
||||||
|
settings.MIDDLEWARE
|
||||||
|
assert len(settings._awx_conf_memoizedcache) == 0 # does not cache MIDDLEWARE
|
||||||
|
settings.registry.register('AWX_VAR', field_class=fields.CharField, category=_('System'), category_slug='system')
|
||||||
|
settings._wrapped.__dict__['all_supported_settings'] = ['AWX_VAR'] # because it is cached_property
|
||||||
|
settings._awx_conf_memoizedcache.clear()
|
||||||
|
assert settings.AWX_VAR == 'DEFAULT'
|
||||||
|
assert len(settings._awx_conf_memoizedcache) == 1 # caches registered settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.defined_in_file(AWX_VAR='DEFAULT')
|
||||||
|
def test_in_memory_cache_works(settings):
|
||||||
|
settings._awx_conf_memoizedcache.clear()
|
||||||
|
settings.registry.register('AWX_VAR', field_class=fields.CharField, category=_('System'), category_slug='system')
|
||||||
|
settings._wrapped.__dict__['all_supported_settings'] = ['AWX_VAR']
|
||||||
|
|
||||||
|
settings._awx_conf_memoizedcache.clear()
|
||||||
|
|
||||||
|
with mock.patch('awx.conf.settings.SettingsWrapper._get_local', return_value='DEFAULT') as mock_get:
|
||||||
|
assert settings.AWX_VAR == 'DEFAULT'
|
||||||
|
mock_get.assert_called_once_with('AWX_VAR')
|
||||||
|
|
||||||
|
with mock.patch.object(settings, '_get_local') as mock_get:
|
||||||
|
assert settings.AWX_VAR == 'DEFAULT'
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|||||||
@@ -1440,7 +1440,7 @@ msgstr "指定した認証情報は無効 (HTTP 401) です。"
|
|||||||
|
|
||||||
#: awx/api/views/root.py:193 awx/api/views/root.py:234
|
#: awx/api/views/root.py:193 awx/api/views/root.py:234
|
||||||
msgid "Unable to connect to proxy server."
|
msgid "Unable to connect to proxy server."
|
||||||
msgstr "プロキシサーバーに接続できません。"
|
msgstr "プロキシーサーバーに接続できません。"
|
||||||
|
|
||||||
#: awx/api/views/root.py:195 awx/api/views/root.py:236
|
#: awx/api/views/root.py:195 awx/api/views/root.py:236
|
||||||
msgid "Could not connect to subscription service."
|
msgid "Could not connect to subscription service."
|
||||||
@@ -1976,7 +1976,7 @@ msgstr "リモートホスト名または IP を判別するために検索す
|
|||||||
|
|
||||||
#: awx/main/conf.py:85
|
#: awx/main/conf.py:85
|
||||||
msgid "Proxy IP Allowed List"
|
msgid "Proxy IP Allowed List"
|
||||||
msgstr "プロキシ IP 許可リスト"
|
msgstr "プロキシー IP 許可リスト"
|
||||||
|
|
||||||
#: awx/main/conf.py:87
|
#: awx/main/conf.py:87
|
||||||
msgid ""
|
msgid ""
|
||||||
@@ -2198,7 +2198,7 @@ msgid ""
|
|||||||
"Follow symbolic links when scanning for playbooks. Be aware that setting "
|
"Follow symbolic links when scanning for playbooks. Be aware that setting "
|
||||||
"this to True can lead to infinite recursion if a link points to a parent "
|
"this to True can lead to infinite recursion if a link points to a parent "
|
||||||
"directory of itself."
|
"directory of itself."
|
||||||
msgstr "Playbook をスキャンするときは、シンボリックリンクをたどってください。リンクがそれ自体の親ディレクトリーを指している場合は、これを True に設定すると、無限再帰が発生する可能性があることに注意してください。"
|
msgstr "Playbook のスキャン時にシンボリックリンクをたどります。リンクが親ディレクトリーを参照している場合には、この設定を True に指定すると無限再帰が発生する可能性があります。"
|
||||||
|
|
||||||
#: awx/main/conf.py:337
|
#: awx/main/conf.py:337
|
||||||
msgid "Ignore Ansible Galaxy SSL Certificate Verification"
|
msgid "Ignore Ansible Galaxy SSL Certificate Verification"
|
||||||
@@ -2499,7 +2499,7 @@ msgstr "Insights for Ansible Automation Platform の最終収集日。"
|
|||||||
msgid ""
|
msgid ""
|
||||||
"Last gathered entries for expensive collectors for Insights for Ansible "
|
"Last gathered entries for expensive collectors for Insights for Ansible "
|
||||||
"Automation Platform."
|
"Automation Platform."
|
||||||
msgstr "Insights for Ansible Automation Platform の高価なコレクターの最後に収集されたエントリー。"
|
msgstr "Insights for Ansible Automation Platform でコストがかかっているコレクターに関して最後に収集されたエントリー"
|
||||||
|
|
||||||
#: awx/main/conf.py:686
|
#: awx/main/conf.py:686
|
||||||
msgid "Insights for Ansible Automation Platform Gather Interval"
|
msgid "Insights for Ansible Automation Platform Gather Interval"
|
||||||
@@ -3692,7 +3692,7 @@ msgstr "タスクの開始"
|
|||||||
|
|
||||||
#: awx/main/models/events.py:189
|
#: awx/main/models/events.py:189
|
||||||
msgid "Variables Prompted"
|
msgid "Variables Prompted"
|
||||||
msgstr "変数のプロモート"
|
msgstr "提示される変数"
|
||||||
|
|
||||||
#: awx/main/models/events.py:190
|
#: awx/main/models/events.py:190
|
||||||
msgid "Gathering Facts"
|
msgid "Gathering Facts"
|
||||||
@@ -3741,15 +3741,15 @@ msgstr "エラー"
|
|||||||
|
|
||||||
#: awx/main/models/execution_environments.py:17
|
#: awx/main/models/execution_environments.py:17
|
||||||
msgid "Always pull container before running."
|
msgid "Always pull container before running."
|
||||||
msgstr "実行前に必ずコンテナーをプルしてください。"
|
msgstr "実行前に必ずコンテナーをプルする"
|
||||||
|
|
||||||
#: awx/main/models/execution_environments.py:18
|
#: awx/main/models/execution_environments.py:18
|
||||||
msgid "Only pull the image if not present before running."
|
msgid "Only pull the image if not present before running."
|
||||||
msgstr "実行する前に、存在しない場合にのみイメージをプルしてください。"
|
msgstr "イメージが存在しない場合のみ実行前にプルする"
|
||||||
|
|
||||||
#: awx/main/models/execution_environments.py:19
|
#: awx/main/models/execution_environments.py:19
|
||||||
msgid "Never pull container before running."
|
msgid "Never pull container before running."
|
||||||
msgstr "実行前にコンテナーをプルしないでください。"
|
msgstr "実行前にコンテナーをプルしない"
|
||||||
|
|
||||||
#: awx/main/models/execution_environments.py:29
|
#: awx/main/models/execution_environments.py:29
|
||||||
msgid ""
|
msgid ""
|
||||||
@@ -5228,7 +5228,7 @@ msgid ""
|
|||||||
"SSL) or \"ldaps://ldap.example.com:636\" (SSL). Multiple LDAP servers may be "
|
"SSL) or \"ldaps://ldap.example.com:636\" (SSL). Multiple LDAP servers may be "
|
||||||
"specified by separating with spaces or commas. LDAP authentication is "
|
"specified by separating with spaces or commas. LDAP authentication is "
|
||||||
"disabled if this parameter is empty."
|
"disabled if this parameter is empty."
|
||||||
msgstr "\"ldap://ldap.example.com:389\" (非 SSL) または \"ldaps://ldap.example.com:636\" (SSL) などの LDAP サーバーに接続する URI です。複数の LDAP サーバーをスペースまたはカンマで区切って指定できます。LDAP 認証は、このパラメーターが空の場合は無効になります。"
|
msgstr "\"ldap://ldap.example.com:389\" (非 SSL) または \"ldaps://ldap.example.com:636\" (SSL) などの LDAP サーバーに接続する URI です。複数の LDAP サーバーをスペースまたはコンマで区切って指定できます。LDAP 認証は、このパラメーターが空の場合は無効になります。"
|
||||||
|
|
||||||
#: awx/sso/conf.py:170 awx/sso/conf.py:187 awx/sso/conf.py:198
|
#: awx/sso/conf.py:170 awx/sso/conf.py:187 awx/sso/conf.py:198
|
||||||
#: awx/sso/conf.py:209 awx/sso/conf.py:226 awx/sso/conf.py:244
|
#: awx/sso/conf.py:209 awx/sso/conf.py:226 awx/sso/conf.py:244
|
||||||
@@ -6237,3 +6237,4 @@ msgstr "%s が現在アップグレード中です。"
|
|||||||
#: awx/ui/urls.py:24
|
#: awx/ui/urls.py:24
|
||||||
msgid "This page will refresh when complete."
|
msgid "This page will refresh when complete."
|
||||||
msgstr "このページは完了すると更新されます。"
|
msgstr "このページは完了すると更新されます。"
|
||||||
|
|
||||||
|
|||||||
@@ -956,7 +956,7 @@ msgstr "인스턴스 그룹의 인스턴스"
|
|||||||
|
|
||||||
#: awx/api/views/__init__.py:450
|
#: awx/api/views/__init__.py:450
|
||||||
msgid "Schedules"
|
msgid "Schedules"
|
||||||
msgstr "일정"
|
msgstr "스케줄"
|
||||||
|
|
||||||
#: awx/api/views/__init__.py:464
|
#: awx/api/views/__init__.py:464
|
||||||
msgid "Schedule Recurrence Rule Preview"
|
msgid "Schedule Recurrence Rule Preview"
|
||||||
@@ -3261,7 +3261,7 @@ msgstr "JSON 또는 YAML 구문을 사용하여 인젝터를 입력합니다.
|
|||||||
#: awx/main/models/credential/__init__.py:412
|
#: awx/main/models/credential/__init__.py:412
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "adding %s credential type"
|
msgid "adding %s credential type"
|
||||||
msgstr "인증 정보 유형 %s 추가 중"
|
msgstr "인증 정보 유형 %s 추가 중"
|
||||||
|
|
||||||
#: awx/main/models/credential/__init__.py:590
|
#: awx/main/models/credential/__init__.py:590
|
||||||
#: awx/main/models/credential/__init__.py:672
|
#: awx/main/models/credential/__init__.py:672
|
||||||
@@ -6237,3 +6237,4 @@ msgstr "%s 현재 업그레이드 중입니다."
|
|||||||
#: awx/ui/urls.py:24
|
#: awx/ui/urls.py:24
|
||||||
msgid "This page will refresh when complete."
|
msgid "This page will refresh when complete."
|
||||||
msgstr "완료되면 이 페이지가 새로 고침됩니다."
|
msgstr "완료되면 이 페이지가 새로 고침됩니다."
|
||||||
|
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ msgstr "SCM track_submodules 只能用于 git 项目。"
|
|||||||
msgid ""
|
msgid ""
|
||||||
"Only Container Registry credentials can be associated with an Execution "
|
"Only Container Registry credentials can be associated with an Execution "
|
||||||
"Environment"
|
"Environment"
|
||||||
msgstr "只有容器 registry 凭证可以与执行环境关联"
|
msgstr "只有容器注册表凭证才可以与执行环境关联"
|
||||||
|
|
||||||
#: awx/api/serializers.py:1440
|
#: awx/api/serializers.py:1440
|
||||||
msgid "Cannot change the organization of an execution environment"
|
msgid "Cannot change the organization of an execution environment"
|
||||||
@@ -629,7 +629,7 @@ msgstr "不支持在不替换的情况下在启动时删除 {} 凭证。提供
|
|||||||
|
|
||||||
#: awx/api/serializers.py:4338
|
#: awx/api/serializers.py:4338
|
||||||
msgid "The inventory associated with this Workflow is being deleted."
|
msgid "The inventory associated with this Workflow is being deleted."
|
||||||
msgstr "与此 Workflow 关联的清单将被删除。"
|
msgstr "与此工作流关联的清单将被删除。"
|
||||||
|
|
||||||
#: awx/api/serializers.py:4405
|
#: awx/api/serializers.py:4405
|
||||||
msgid "Message type '{}' invalid, must be either 'message' or 'body'"
|
msgid "Message type '{}' invalid, must be either 'message' or 'body'"
|
||||||
@@ -3229,7 +3229,7 @@ msgstr "云"
|
|||||||
#: awx/main/models/credential/__init__.py:336
|
#: awx/main/models/credential/__init__.py:336
|
||||||
#: awx/main/models/credential/__init__.py:1113
|
#: awx/main/models/credential/__init__.py:1113
|
||||||
msgid "Container Registry"
|
msgid "Container Registry"
|
||||||
msgstr "容器 Registry"
|
msgstr "容器注册表"
|
||||||
|
|
||||||
#: awx/main/models/credential/__init__.py:337
|
#: awx/main/models/credential/__init__.py:337
|
||||||
msgid "Personal Access Token"
|
msgid "Personal Access Token"
|
||||||
@@ -3560,7 +3560,7 @@ msgstr "身份验证 URL"
|
|||||||
|
|
||||||
#: awx/main/models/credential/__init__.py:1120
|
#: awx/main/models/credential/__init__.py:1120
|
||||||
msgid "Authentication endpoint for the container registry."
|
msgid "Authentication endpoint for the container registry."
|
||||||
msgstr "容器 registry 的身份验证端点。"
|
msgstr "容器注册表的身份验证端点。"
|
||||||
|
|
||||||
#: awx/main/models/credential/__init__.py:1130
|
#: awx/main/models/credential/__init__.py:1130
|
||||||
msgid "Password or Token"
|
msgid "Password or Token"
|
||||||
@@ -3764,7 +3764,7 @@ msgstr "镜像位置"
|
|||||||
msgid ""
|
msgid ""
|
||||||
"The full image location, including the container registry, image name, and "
|
"The full image location, including the container registry, image name, and "
|
||||||
"version tag."
|
"version tag."
|
||||||
msgstr "完整镜像位置,包括容器 registry、镜像名称和版本标签。"
|
msgstr "完整镜像位置,包括容器注册表、镜像名称和版本标签。"
|
||||||
|
|
||||||
#: awx/main/models/execution_environments.py:51
|
#: awx/main/models/execution_environments.py:51
|
||||||
msgid "Pull image before running?"
|
msgid "Pull image before running?"
|
||||||
@@ -6239,3 +6239,4 @@ msgstr "%s 当前正在升级。"
|
|||||||
#: awx/ui/urls.py:24
|
#: awx/ui/urls.py:24
|
||||||
msgid "This page will refresh when complete."
|
msgid "This page will refresh when complete."
|
||||||
msgstr "完成后,此页面会刷新。"
|
msgstr "完成后,此页面会刷新。"
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ from django.contrib.sessions.models import Session
|
|||||||
from django.utils.timezone import now, timedelta
|
from django.utils.timezone import now, timedelta
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from psycopg2.errors import UntranslatableCharacter
|
|
||||||
|
|
||||||
from awx.conf.license import get_license
|
from awx.conf.license import get_license
|
||||||
from awx.main.utils import get_awx_version, camelcase_to_underscore, datetime_hook
|
from awx.main.utils import get_awx_version, camelcase_to_underscore, datetime_hook
|
||||||
from awx.main import models
|
from awx.main import models
|
||||||
from awx.main.analytics import register
|
from awx.main.analytics import register
|
||||||
|
from awx.main.scheduler.task_manager_models import TaskManagerInstances
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This module is used to define metrics collected by awx.main.analytics.gather()
|
This module is used to define metrics collected by awx.main.analytics.gather()
|
||||||
@@ -131,7 +130,7 @@ def config(since, **kwargs):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@register('counts', '1.1', description=_('Counts of objects such as organizations, inventories, and projects'))
|
@register('counts', '1.2', description=_('Counts of objects such as organizations, inventories, and projects'))
|
||||||
def counts(since, **kwargs):
|
def counts(since, **kwargs):
|
||||||
counts = {}
|
counts = {}
|
||||||
for cls in (
|
for cls in (
|
||||||
@@ -174,6 +173,13 @@ def counts(since, **kwargs):
|
|||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
counts['pending_jobs'] = models.UnifiedJob.objects.exclude(launch_type='sync').filter(status__in=('pending',)).count()
|
counts['pending_jobs'] = models.UnifiedJob.objects.exclude(launch_type='sync').filter(status__in=('pending',)).count()
|
||||||
|
if connection.vendor == 'postgresql':
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(f"select count(*) from pg_stat_activity where datname=\'{connection.settings_dict['NAME']}\'")
|
||||||
|
counts['database_connections'] = cursor.fetchone()[0]
|
||||||
|
else:
|
||||||
|
# We should be using postgresql, but if we do that change that ever we should change the below value
|
||||||
|
counts['database_connections'] = 1
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
@@ -230,25 +236,25 @@ def projects_by_scm_type(since, **kwargs):
|
|||||||
@register('instance_info', '1.2', description=_('Cluster topology and capacity'))
|
@register('instance_info', '1.2', description=_('Cluster topology and capacity'))
|
||||||
def instance_info(since, include_hostnames=False, **kwargs):
|
def instance_info(since, include_hostnames=False, **kwargs):
|
||||||
info = {}
|
info = {}
|
||||||
instances = models.Instance.objects.values_list('hostname').values(
|
# Use same method that the TaskManager does to compute consumed capacity without querying all running jobs for each Instance
|
||||||
'uuid', 'version', 'capacity', 'cpu', 'memory', 'managed_by_policy', 'hostname', 'enabled'
|
active_tasks = models.UnifiedJob.objects.filter(status__in=['running', 'waiting']).only('task_impact', 'controller_node', 'execution_node')
|
||||||
)
|
tm_instances = TaskManagerInstances(active_tasks, instance_fields=['uuid', 'version', 'capacity', 'cpu', 'memory', 'managed_by_policy', 'enabled'])
|
||||||
for instance in instances:
|
for tm_instance in tm_instances.instances_by_hostname.values():
|
||||||
consumed_capacity = sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'], status__in=('running', 'waiting')))
|
instance = tm_instance.obj
|
||||||
instance_info = {
|
instance_info = {
|
||||||
'uuid': instance['uuid'],
|
'uuid': instance.uuid,
|
||||||
'version': instance['version'],
|
'version': instance.version,
|
||||||
'capacity': instance['capacity'],
|
'capacity': instance.capacity,
|
||||||
'cpu': instance['cpu'],
|
'cpu': instance.cpu,
|
||||||
'memory': instance['memory'],
|
'memory': instance.memory,
|
||||||
'managed_by_policy': instance['managed_by_policy'],
|
'managed_by_policy': instance.managed_by_policy,
|
||||||
'enabled': instance['enabled'],
|
'enabled': instance.enabled,
|
||||||
'consumed_capacity': consumed_capacity,
|
'consumed_capacity': tm_instance.consumed_capacity,
|
||||||
'remaining_capacity': instance['capacity'] - consumed_capacity,
|
'remaining_capacity': instance.capacity - tm_instance.consumed_capacity,
|
||||||
}
|
}
|
||||||
if include_hostnames is True:
|
if include_hostnames is True:
|
||||||
instance_info['hostname'] = instance['hostname']
|
instance_info['hostname'] = instance.hostname
|
||||||
info[instance['uuid']] = instance_info
|
info[instance.uuid] = instance_info
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
@@ -378,10 +384,7 @@ def _events_table(since, full_path, until, tbl, where_column, project_job_create
|
|||||||
WHERE ({tbl}.{where_column} > '{since.isoformat()}' AND {tbl}.{where_column} <= '{until.isoformat()}')) TO STDOUT WITH CSV HEADER'''
|
WHERE ({tbl}.{where_column} > '{since.isoformat()}' AND {tbl}.{where_column} <= '{until.isoformat()}')) TO STDOUT WITH CSV HEADER'''
|
||||||
return query
|
return query
|
||||||
|
|
||||||
try:
|
return _copy_table(table='events', query=query(fr"replace({tbl}.event_data, '\u', '\u005cu')::jsonb"), path=full_path)
|
||||||
return _copy_table(table='events', query=query(f"{tbl}.event_data::jsonb"), path=full_path)
|
|
||||||
except UntranslatableCharacter:
|
|
||||||
return _copy_table(table='events', query=query(f"replace({tbl}.event_data::text, '\\u0000', '')::jsonb"), path=full_path)
|
|
||||||
|
|
||||||
|
|
||||||
@register('events_table', '1.5', format='csv', description=_('Automation task records'), expensive=four_hour_slicing)
|
@register('events_table', '1.5', format='csv', description=_('Automation task records'), expensive=four_hour_slicing)
|
||||||
@@ -394,7 +397,7 @@ def events_table_partitioned_modified(since, full_path, until, **kwargs):
|
|||||||
return _events_table(since, full_path, until, 'main_jobevent', 'modified', project_job_created=True, **kwargs)
|
return _events_table(since, full_path, until, 'main_jobevent', 'modified', project_job_created=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@register('unified_jobs_table', '1.3', format='csv', description=_('Data on jobs run'), expensive=four_hour_slicing)
|
@register('unified_jobs_table', '1.4', format='csv', description=_('Data on jobs run'), expensive=four_hour_slicing)
|
||||||
def unified_jobs_table(since, full_path, until, **kwargs):
|
def unified_jobs_table(since, full_path, until, **kwargs):
|
||||||
unified_job_query = '''COPY (SELECT main_unifiedjob.id,
|
unified_job_query = '''COPY (SELECT main_unifiedjob.id,
|
||||||
main_unifiedjob.polymorphic_ctype_id,
|
main_unifiedjob.polymorphic_ctype_id,
|
||||||
@@ -420,7 +423,8 @@ def unified_jobs_table(since, full_path, until, **kwargs):
|
|||||||
main_unifiedjob.job_explanation,
|
main_unifiedjob.job_explanation,
|
||||||
main_unifiedjob.instance_group_id,
|
main_unifiedjob.instance_group_id,
|
||||||
main_unifiedjob.installed_collections,
|
main_unifiedjob.installed_collections,
|
||||||
main_unifiedjob.ansible_version
|
main_unifiedjob.ansible_version,
|
||||||
|
main_job.forks
|
||||||
FROM main_unifiedjob
|
FROM main_unifiedjob
|
||||||
JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id
|
JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id
|
||||||
LEFT JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id
|
LEFT JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ def metrics():
|
|||||||
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license', registry=REGISTRY)
|
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license', registry=REGISTRY)
|
||||||
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license', registry=REGISTRY)
|
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license', registry=REGISTRY)
|
||||||
|
|
||||||
|
DATABASE_CONNECTIONS = Gauge('awx_database_connections_total', 'Number of connections to database', registry=REGISTRY)
|
||||||
|
|
||||||
license_info = get_license()
|
license_info = get_license()
|
||||||
SYSTEM_INFO.info(
|
SYSTEM_INFO.info(
|
||||||
{
|
{
|
||||||
@@ -163,6 +165,8 @@ def metrics():
|
|||||||
USER_SESSIONS.labels(type='user').set(current_counts['active_user_sessions'])
|
USER_SESSIONS.labels(type='user').set(current_counts['active_user_sessions'])
|
||||||
USER_SESSIONS.labels(type='anonymous').set(current_counts['active_anonymous_sessions'])
|
USER_SESSIONS.labels(type='anonymous').set(current_counts['active_anonymous_sessions'])
|
||||||
|
|
||||||
|
DATABASE_CONNECTIONS.set(current_counts['database_connections'])
|
||||||
|
|
||||||
all_job_data = job_counts(None)
|
all_job_data = job_counts(None)
|
||||||
statuses = all_job_data.get('status', {})
|
statuses = all_job_data.get('status', {})
|
||||||
for status, value in statuses.items():
|
for status, value in statuses.items():
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.apps import apps
|
|||||||
from awx.main.consumers import emit_channel_notification
|
from awx.main.consumers import emit_channel_notification
|
||||||
|
|
||||||
root_key = 'awx_metrics'
|
root_key = 'awx_metrics'
|
||||||
logger = logging.getLogger('awx.main.wsbroadcast')
|
logger = logging.getLogger('awx.main.analytics')
|
||||||
|
|
||||||
|
|
||||||
class BaseM:
|
class BaseM:
|
||||||
@@ -16,16 +16,22 @@ class BaseM:
|
|||||||
self.field = field
|
self.field = field
|
||||||
self.help_text = help_text
|
self.help_text = help_text
|
||||||
self.current_value = 0
|
self.current_value = 0
|
||||||
|
self.metric_has_changed = False
|
||||||
|
|
||||||
def clear_value(self, conn):
|
def reset_value(self, conn):
|
||||||
conn.hset(root_key, self.field, 0)
|
conn.hset(root_key, self.field, 0)
|
||||||
self.current_value = 0
|
self.current_value = 0
|
||||||
|
|
||||||
def inc(self, value):
|
def inc(self, value):
|
||||||
self.current_value += value
|
self.current_value += value
|
||||||
|
self.metric_has_changed = True
|
||||||
|
|
||||||
def set(self, value):
|
def set(self, value):
|
||||||
self.current_value = value
|
self.current_value = value
|
||||||
|
self.metric_has_changed = True
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.current_value
|
||||||
|
|
||||||
def decode(self, conn):
|
def decode(self, conn):
|
||||||
value = conn.hget(root_key, self.field)
|
value = conn.hget(root_key, self.field)
|
||||||
@@ -34,7 +40,9 @@ class BaseM:
|
|||||||
def to_prometheus(self, instance_data):
|
def to_prometheus(self, instance_data):
|
||||||
output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} gauge\n"
|
output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} gauge\n"
|
||||||
for instance in instance_data:
|
for instance in instance_data:
|
||||||
output_text += f'{self.field}{{node="{instance}"}} {instance_data[instance][self.field]}\n'
|
if self.field in instance_data[instance]:
|
||||||
|
# on upgrade, if there are stale instances, we can end up with issues where new metrics are not present
|
||||||
|
output_text += f'{self.field}{{node="{instance}"}} {instance_data[instance][self.field]}\n'
|
||||||
return output_text
|
return output_text
|
||||||
|
|
||||||
|
|
||||||
@@ -46,8 +54,10 @@ class FloatM(BaseM):
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def store_value(self, conn):
|
def store_value(self, conn):
|
||||||
conn.hincrbyfloat(root_key, self.field, self.current_value)
|
if self.metric_has_changed:
|
||||||
self.current_value = 0
|
conn.hincrbyfloat(root_key, self.field, self.current_value)
|
||||||
|
self.current_value = 0
|
||||||
|
self.metric_has_changed = False
|
||||||
|
|
||||||
|
|
||||||
class IntM(BaseM):
|
class IntM(BaseM):
|
||||||
@@ -58,8 +68,10 @@ class IntM(BaseM):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def store_value(self, conn):
|
def store_value(self, conn):
|
||||||
conn.hincrby(root_key, self.field, self.current_value)
|
if self.metric_has_changed:
|
||||||
self.current_value = 0
|
conn.hincrby(root_key, self.field, self.current_value)
|
||||||
|
self.current_value = 0
|
||||||
|
self.metric_has_changed = False
|
||||||
|
|
||||||
|
|
||||||
class SetIntM(BaseM):
|
class SetIntM(BaseM):
|
||||||
@@ -70,10 +82,9 @@ class SetIntM(BaseM):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def store_value(self, conn):
|
def store_value(self, conn):
|
||||||
# do not set value if it has not changed since last time this was called
|
if self.metric_has_changed:
|
||||||
if self.current_value is not None:
|
|
||||||
conn.hset(root_key, self.field, self.current_value)
|
conn.hset(root_key, self.field, self.current_value)
|
||||||
self.current_value = None
|
self.metric_has_changed = False
|
||||||
|
|
||||||
|
|
||||||
class SetFloatM(SetIntM):
|
class SetFloatM(SetIntM):
|
||||||
@@ -94,13 +105,13 @@ class HistogramM(BaseM):
|
|||||||
self.sum = IntM(field + '_sum', '')
|
self.sum = IntM(field + '_sum', '')
|
||||||
super(HistogramM, self).__init__(field, help_text)
|
super(HistogramM, self).__init__(field, help_text)
|
||||||
|
|
||||||
def clear_value(self, conn):
|
def reset_value(self, conn):
|
||||||
conn.hset(root_key, self.field, 0)
|
conn.hset(root_key, self.field, 0)
|
||||||
self.inf.clear_value(conn)
|
self.inf.reset_value(conn)
|
||||||
self.sum.clear_value(conn)
|
self.sum.reset_value(conn)
|
||||||
for b in self.buckets_to_keys.values():
|
for b in self.buckets_to_keys.values():
|
||||||
b.clear_value(conn)
|
b.reset_value(conn)
|
||||||
super(HistogramM, self).clear_value(conn)
|
super(HistogramM, self).reset_value(conn)
|
||||||
|
|
||||||
def observe(self, value):
|
def observe(self, value):
|
||||||
for b in self.buckets:
|
for b in self.buckets:
|
||||||
@@ -136,7 +147,7 @@ class HistogramM(BaseM):
|
|||||||
|
|
||||||
|
|
||||||
class Metrics:
|
class Metrics:
|
||||||
def __init__(self, auto_pipe_execute=True, instance_name=None):
|
def __init__(self, auto_pipe_execute=False, instance_name=None):
|
||||||
self.pipe = redis.Redis.from_url(settings.BROKER_URL).pipeline()
|
self.pipe = redis.Redis.from_url(settings.BROKER_URL).pipeline()
|
||||||
self.conn = redis.Redis.from_url(settings.BROKER_URL)
|
self.conn = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
self.last_pipe_execute = time.time()
|
self.last_pipe_execute = time.time()
|
||||||
@@ -152,8 +163,14 @@ class Metrics:
|
|||||||
Instance = apps.get_model('main', 'Instance')
|
Instance = apps.get_model('main', 'Instance')
|
||||||
if instance_name:
|
if instance_name:
|
||||||
self.instance_name = instance_name
|
self.instance_name = instance_name
|
||||||
|
elif settings.IS_TESTING():
|
||||||
|
self.instance_name = "awx_testing"
|
||||||
else:
|
else:
|
||||||
self.instance_name = Instance.objects.me().hostname
|
try:
|
||||||
|
self.instance_name = Instance.objects.me().hostname
|
||||||
|
except Exception as e:
|
||||||
|
self.instance_name = settings.CLUSTER_HOST_ID
|
||||||
|
logger.info(f'Instance {self.instance_name} seems to be unregistered, error: {e}')
|
||||||
|
|
||||||
# metric name, help_text
|
# metric name, help_text
|
||||||
METRICSLIST = [
|
METRICSLIST = [
|
||||||
@@ -161,15 +178,39 @@ class Metrics:
|
|||||||
IntM('callback_receiver_events_popped_redis', 'Number of events popped from redis'),
|
IntM('callback_receiver_events_popped_redis', 'Number of events popped from redis'),
|
||||||
IntM('callback_receiver_events_in_memory', 'Current number of events in memory (in transfer from redis to db)'),
|
IntM('callback_receiver_events_in_memory', 'Current number of events in memory (in transfer from redis to db)'),
|
||||||
IntM('callback_receiver_batch_events_errors', 'Number of times batch insertion failed'),
|
IntM('callback_receiver_batch_events_errors', 'Number of times batch insertion failed'),
|
||||||
FloatM('callback_receiver_events_insert_db_seconds', 'Time spent saving events to database'),
|
FloatM('callback_receiver_events_insert_db_seconds', 'Total time spent saving events to database'),
|
||||||
IntM('callback_receiver_events_insert_db', 'Number of events batch inserted into database'),
|
IntM('callback_receiver_events_insert_db', 'Number of events batch inserted into database'),
|
||||||
IntM('callback_receiver_events_broadcast', 'Number of events broadcast to other control plane nodes'),
|
IntM('callback_receiver_events_broadcast', 'Number of events broadcast to other control plane nodes'),
|
||||||
HistogramM(
|
HistogramM(
|
||||||
'callback_receiver_batch_events_insert_db', 'Number of events batch inserted into database', settings.SUBSYSTEM_METRICS_BATCH_INSERT_BUCKETS
|
'callback_receiver_batch_events_insert_db', 'Number of events batch inserted into database', settings.SUBSYSTEM_METRICS_BATCH_INSERT_BUCKETS
|
||||||
),
|
),
|
||||||
|
SetFloatM('callback_receiver_event_processing_avg_seconds', 'Average processing time per event per callback receiver batch'),
|
||||||
FloatM('subsystem_metrics_pipe_execute_seconds', 'Time spent saving metrics to redis'),
|
FloatM('subsystem_metrics_pipe_execute_seconds', 'Time spent saving metrics to redis'),
|
||||||
IntM('subsystem_metrics_pipe_execute_calls', 'Number of calls to pipe_execute'),
|
IntM('subsystem_metrics_pipe_execute_calls', 'Number of calls to pipe_execute'),
|
||||||
FloatM('subsystem_metrics_send_metrics_seconds', 'Time spent sending metrics to other nodes'),
|
FloatM('subsystem_metrics_send_metrics_seconds', 'Time spent sending metrics to other nodes'),
|
||||||
|
SetFloatM('task_manager_get_tasks_seconds', 'Time spent in loading tasks from db'),
|
||||||
|
SetFloatM('task_manager_start_task_seconds', 'Time spent starting task'),
|
||||||
|
SetFloatM('task_manager_process_running_tasks_seconds', 'Time spent processing running tasks'),
|
||||||
|
SetFloatM('task_manager_process_pending_tasks_seconds', 'Time spent processing pending tasks'),
|
||||||
|
SetFloatM('task_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
||||||
|
IntM('task_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
||||||
|
SetFloatM('task_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
||||||
|
SetIntM('task_manager_tasks_started', 'Number of tasks started'),
|
||||||
|
SetIntM('task_manager_running_processed', 'Number of running tasks processed'),
|
||||||
|
SetIntM('task_manager_pending_processed', 'Number of pending tasks processed'),
|
||||||
|
SetIntM('task_manager_tasks_blocked', 'Number of tasks blocked from running'),
|
||||||
|
SetFloatM('task_manager_commit_seconds', 'Time spent in db transaction, including on_commit calls'),
|
||||||
|
SetFloatM('dependency_manager_get_tasks_seconds', 'Time spent loading pending tasks from db'),
|
||||||
|
SetFloatM('dependency_manager_generate_dependencies_seconds', 'Time spent generating dependencies for pending tasks'),
|
||||||
|
SetFloatM('dependency_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
||||||
|
IntM('dependency_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
||||||
|
SetFloatM('dependency_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
||||||
|
SetIntM('dependency_manager_pending_processed', 'Number of pending tasks processed'),
|
||||||
|
SetFloatM('workflow_manager__schedule_seconds', 'Time spent in running the entire _schedule'),
|
||||||
|
IntM('workflow_manager__schedule_calls', 'Number of calls to _schedule, after lock is acquired'),
|
||||||
|
SetFloatM('workflow_manager_recorded_timestamp', 'Unix timestamp when metrics were last recorded'),
|
||||||
|
SetFloatM('workflow_manager_spawn_workflow_graph_jobs_seconds', 'Time spent spawning workflow tasks'),
|
||||||
|
SetFloatM('workflow_manager_get_tasks_seconds', 'Time spent loading workflow tasks from db'),
|
||||||
]
|
]
|
||||||
# turn metric list into dictionary with the metric name as a key
|
# turn metric list into dictionary with the metric name as a key
|
||||||
self.METRICS = {}
|
self.METRICS = {}
|
||||||
@@ -179,29 +220,39 @@ class Metrics:
|
|||||||
# track last time metrics were sent to other nodes
|
# track last time metrics were sent to other nodes
|
||||||
self.previous_send_metrics = SetFloatM('send_metrics_time', 'Timestamp of previous send_metrics call')
|
self.previous_send_metrics = SetFloatM('send_metrics_time', 'Timestamp of previous send_metrics call')
|
||||||
|
|
||||||
def clear_values(self):
|
def reset_values(self):
|
||||||
|
# intended to be called once on app startup to reset all metric
|
||||||
|
# values to 0
|
||||||
for m in self.METRICS.values():
|
for m in self.METRICS.values():
|
||||||
m.clear_value(self.conn)
|
m.reset_value(self.conn)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
self.conn.delete(root_key + "_lock")
|
self.conn.delete(root_key + "_lock")
|
||||||
|
for m in self.conn.scan_iter(root_key + '_instance_*'):
|
||||||
|
self.conn.delete(m)
|
||||||
|
|
||||||
def inc(self, field, value):
|
def inc(self, field, value):
|
||||||
if value != 0:
|
if value != 0:
|
||||||
self.METRICS[field].inc(value)
|
self.METRICS[field].inc(value)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
if self.auto_pipe_execute is True:
|
||||||
self.pipe_execute()
|
self.pipe_execute()
|
||||||
|
|
||||||
def set(self, field, value):
|
def set(self, field, value):
|
||||||
self.METRICS[field].set(value)
|
self.METRICS[field].set(value)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
if self.auto_pipe_execute is True:
|
||||||
self.pipe_execute()
|
self.pipe_execute()
|
||||||
|
|
||||||
|
def get(self, field):
|
||||||
|
return self.METRICS[field].get()
|
||||||
|
|
||||||
|
def decode(self, field):
|
||||||
|
return self.METRICS[field].decode(self.conn)
|
||||||
|
|
||||||
def observe(self, field, value):
|
def observe(self, field, value):
|
||||||
self.METRICS[field].observe(value)
|
self.METRICS[field].observe(value)
|
||||||
self.metrics_have_changed = True
|
self.metrics_have_changed = True
|
||||||
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
if self.auto_pipe_execute is True:
|
||||||
self.pipe_execute()
|
self.pipe_execute()
|
||||||
|
|
||||||
def serialize_local_metrics(self):
|
def serialize_local_metrics(self):
|
||||||
@@ -249,8 +300,8 @@ class Metrics:
|
|||||||
|
|
||||||
def send_metrics(self):
|
def send_metrics(self):
|
||||||
# more than one thread could be calling this at the same time, so should
|
# more than one thread could be calling this at the same time, so should
|
||||||
# get acquire redis lock before sending metrics
|
# acquire redis lock before sending metrics
|
||||||
lock = self.conn.lock(root_key + '_lock', thread_local=False)
|
lock = self.conn.lock(root_key + '_lock')
|
||||||
if not lock.acquire(blocking=False):
|
if not lock.acquire(blocking=False):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -266,7 +317,12 @@ class Metrics:
|
|||||||
self.previous_send_metrics.set(current_time)
|
self.previous_send_metrics.set(current_time)
|
||||||
self.previous_send_metrics.store_value(self.conn)
|
self.previous_send_metrics.store_value(self.conn)
|
||||||
finally:
|
finally:
|
||||||
lock.release()
|
try:
|
||||||
|
lock.release()
|
||||||
|
except Exception as exc:
|
||||||
|
# After system failures, we might throw redis.exceptions.LockNotOwnedError
|
||||||
|
# this is to avoid print a Traceback, and importantly, avoid raising an exception into parent context
|
||||||
|
logger.warning(f'Error releasing subsystem metrics redis lock, error: {str(exc)}')
|
||||||
|
|
||||||
def load_other_metrics(self, request):
|
def load_other_metrics(self, request):
|
||||||
# data received from other nodes are stored in their own keys
|
# data received from other nodes are stored in their own keys
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ register(
|
|||||||
label=_('Default Job Idle Timeout'),
|
label=_('Default Job Idle Timeout'),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
'If no output is detected from ansible in this number of seconds the execution will be terminated. '
|
'If no output is detected from ansible in this number of seconds the execution will be terminated. '
|
||||||
'Use value of 0 to used default idle_timeout is 600s.'
|
'Use value of 0 to indicate that no idle timeout should be imposed.'
|
||||||
),
|
),
|
||||||
category=_('Jobs'),
|
category=_('Jobs'),
|
||||||
category_slug='jobs',
|
category_slug='jobs',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import select
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import connection as pg_connection
|
||||||
|
|
||||||
|
|
||||||
NOT_READY = ([], [], [])
|
NOT_READY = ([], [], [])
|
||||||
@@ -15,7 +16,6 @@ def get_local_queuename():
|
|||||||
|
|
||||||
class PubSub(object):
|
class PubSub(object):
|
||||||
def __init__(self, conn):
|
def __init__(self, conn):
|
||||||
assert conn.autocommit, "Connection must be in autocommit mode."
|
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
|
|
||||||
def listen(self, channel):
|
def listen(self, channel):
|
||||||
@@ -31,6 +31,9 @@ class PubSub(object):
|
|||||||
cur.execute('SELECT pg_notify(%s, %s);', (channel, payload))
|
cur.execute('SELECT pg_notify(%s, %s);', (channel, payload))
|
||||||
|
|
||||||
def events(self, select_timeout=5, yield_timeouts=False):
|
def events(self, select_timeout=5, yield_timeouts=False):
|
||||||
|
if not self.conn.autocommit:
|
||||||
|
raise RuntimeError('Listening for events can only be done in autocommit mode')
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if select.select([self.conn], [], [], select_timeout) == NOT_READY:
|
if select.select([self.conn], [], [], select_timeout) == NOT_READY:
|
||||||
if yield_timeouts:
|
if yield_timeouts:
|
||||||
@@ -45,11 +48,32 @@ class PubSub(object):
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def pg_bus_conn():
|
def pg_bus_conn(new_connection=False):
|
||||||
conf = settings.DATABASES['default']
|
'''
|
||||||
conn = psycopg2.connect(dbname=conf['NAME'], host=conf['HOST'], user=conf['USER'], password=conf['PASSWORD'], port=conf['PORT'], **conf.get("OPTIONS", {}))
|
Any listeners probably want to establish a new database connection,
|
||||||
# Django connection.cursor().connection doesn't have autocommit=True on
|
separate from the Django connection used for queries, because that will prevent
|
||||||
conn.set_session(autocommit=True)
|
losing connection to the channel whenever a .close() happens.
|
||||||
|
|
||||||
|
Any publishers probably want to use the existing connection
|
||||||
|
so that messages follow postgres transaction rules
|
||||||
|
https://www.postgresql.org/docs/current/sql-notify.html
|
||||||
|
'''
|
||||||
|
|
||||||
|
if new_connection:
|
||||||
|
conf = settings.DATABASES['default']
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
dbname=conf['NAME'], host=conf['HOST'], user=conf['USER'], password=conf['PASSWORD'], port=conf['PORT'], **conf.get("OPTIONS", {})
|
||||||
|
)
|
||||||
|
# Django connection.cursor().connection doesn't have autocommit=True on by default
|
||||||
|
conn.set_session(autocommit=True)
|
||||||
|
else:
|
||||||
|
if pg_connection.connection is None:
|
||||||
|
pg_connection.connect()
|
||||||
|
if pg_connection.connection is None:
|
||||||
|
raise RuntimeError('Unexpectedly could not connect to postgres for pg_notify actions')
|
||||||
|
conn = pg_connection.connection
|
||||||
|
|
||||||
pubsub = PubSub(conn)
|
pubsub = PubSub(conn)
|
||||||
yield pubsub
|
yield pubsub
|
||||||
conn.close()
|
if new_connection:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class Control(object):
|
|||||||
reply_queue = Control.generate_reply_queue_name()
|
reply_queue = Control.generate_reply_queue_name()
|
||||||
self.result = None
|
self.result = None
|
||||||
|
|
||||||
with pg_bus_conn() as conn:
|
with pg_bus_conn(new_connection=True) as conn:
|
||||||
conn.listen(reply_queue)
|
conn.listen(reply_queue)
|
||||||
conn.notify(self.queuename, json.dumps({'control': command, 'reply_to': reply_queue}))
|
conn.notify(self.queuename, json.dumps({'control': command, 'reply_to': reply_queue}))
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,14 @@ from queue import Full as QueueFull, Empty as QueueEmpty
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import connection as django_connection, connections
|
from django.db import connection as django_connection, connections
|
||||||
from django.core.cache import cache as django_cache
|
from django.core.cache import cache as django_cache
|
||||||
|
from django.utils.timezone import now as tz_now
|
||||||
from django_guid import set_guid
|
from django_guid import set_guid
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from awx.main.models import UnifiedJob
|
from awx.main.models import UnifiedJob
|
||||||
from awx.main.dispatch import reaper
|
from awx.main.dispatch import reaper
|
||||||
from awx.main.utils.common import convert_mem_str_to_bytes, get_mem_effective_capacity
|
from awx.main.utils.common import convert_mem_str_to_bytes, get_mem_effective_capacity, log_excess_runtime
|
||||||
|
|
||||||
if 'run_callback_receiver' in sys.argv:
|
if 'run_callback_receiver' in sys.argv:
|
||||||
logger = logging.getLogger('awx.main.commands.run_callback_receiver')
|
logger = logging.getLogger('awx.main.commands.run_callback_receiver')
|
||||||
@@ -328,12 +329,16 @@ class AutoscalePool(WorkerPool):
|
|||||||
# Get same number as max forks based on memory, this function takes memory as bytes
|
# Get same number as max forks based on memory, this function takes memory as bytes
|
||||||
self.max_workers = get_mem_effective_capacity(total_memory_gb * 2**30)
|
self.max_workers = get_mem_effective_capacity(total_memory_gb * 2**30)
|
||||||
|
|
||||||
|
# add magic prime number of extra workers to ensure
|
||||||
|
# we have a few extra workers to run the heartbeat
|
||||||
|
self.max_workers += 7
|
||||||
|
|
||||||
# max workers can't be less than min_workers
|
# max workers can't be less than min_workers
|
||||||
self.max_workers = max(self.min_workers, self.max_workers)
|
self.max_workers = max(self.min_workers, self.max_workers)
|
||||||
|
|
||||||
def debug(self, *args, **kwargs):
|
# the task manager enforces settings.TASK_MANAGER_TIMEOUT on its own
|
||||||
self.cleanup()
|
# but if the task takes longer than the time defined here, we will force it to stop here
|
||||||
return super(AutoscalePool, self).debug(*args, **kwargs)
|
self.task_manager_timeout = settings.TASK_MANAGER_TIMEOUT + settings.TASK_MANAGER_TIMEOUT_GRACE_PERIOD
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_grow(self):
|
def should_grow(self):
|
||||||
@@ -351,6 +356,7 @@ class AutoscalePool(WorkerPool):
|
|||||||
def debug_meta(self):
|
def debug_meta(self):
|
||||||
return 'min={} max={}'.format(self.min_workers, self.max_workers)
|
return 'min={} max={}'.format(self.min_workers, self.max_workers)
|
||||||
|
|
||||||
|
@log_excess_runtime(logger)
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""
|
"""
|
||||||
Perform some internal account and cleanup. This is run on
|
Perform some internal account and cleanup. This is run on
|
||||||
@@ -359,8 +365,6 @@ class AutoscalePool(WorkerPool):
|
|||||||
1. Discover worker processes that exited, and recover messages they
|
1. Discover worker processes that exited, and recover messages they
|
||||||
were handling.
|
were handling.
|
||||||
2. Clean up unnecessary, idle workers.
|
2. Clean up unnecessary, idle workers.
|
||||||
3. Check to see if the database says this node is running any tasks
|
|
||||||
that aren't actually running. If so, reap them.
|
|
||||||
|
|
||||||
IMPORTANT: this function is one of the few places in the dispatcher
|
IMPORTANT: this function is one of the few places in the dispatcher
|
||||||
(aside from setting lookups) where we talk to the database. As such,
|
(aside from setting lookups) where we talk to the database. As such,
|
||||||
@@ -401,13 +405,15 @@ class AutoscalePool(WorkerPool):
|
|||||||
# the task manager to never do more work
|
# the task manager to never do more work
|
||||||
current_task = w.current_task
|
current_task = w.current_task
|
||||||
if current_task and isinstance(current_task, dict):
|
if current_task and isinstance(current_task, dict):
|
||||||
if current_task.get('task', '').endswith('tasks.run_task_manager'):
|
endings = ['tasks.task_manager', 'tasks.dependency_manager', 'tasks.workflow_manager']
|
||||||
|
current_task_name = current_task.get('task', '')
|
||||||
|
if any(current_task_name.endswith(e) for e in endings):
|
||||||
if 'started' not in current_task:
|
if 'started' not in current_task:
|
||||||
w.managed_tasks[current_task['uuid']]['started'] = time.time()
|
w.managed_tasks[current_task['uuid']]['started'] = time.time()
|
||||||
age = time.time() - current_task['started']
|
age = time.time() - current_task['started']
|
||||||
w.managed_tasks[current_task['uuid']]['age'] = age
|
w.managed_tasks[current_task['uuid']]['age'] = age
|
||||||
if age > (60 * 5):
|
if age > self.task_manager_timeout:
|
||||||
logger.error(f'run_task_manager has held the advisory lock for >5m, sending SIGTERM to {w.pid}') # noqa
|
logger.error(f'{current_task_name} has held the advisory lock for {age}, sending SIGTERM to {w.pid}')
|
||||||
os.kill(w.pid, signal.SIGTERM)
|
os.kill(w.pid, signal.SIGTERM)
|
||||||
|
|
||||||
for m in orphaned:
|
for m in orphaned:
|
||||||
@@ -417,13 +423,17 @@ class AutoscalePool(WorkerPool):
|
|||||||
idx = random.choice(range(len(self.workers)))
|
idx = random.choice(range(len(self.workers)))
|
||||||
self.write(idx, m)
|
self.write(idx, m)
|
||||||
|
|
||||||
# if the database says a job is running on this node, but it's *not*,
|
def add_bind_kwargs(self, body):
|
||||||
# then reap it
|
bind_kwargs = body.pop('bind_kwargs', [])
|
||||||
running_uuids = []
|
body.setdefault('kwargs', {})
|
||||||
for worker in self.workers:
|
if 'dispatch_time' in bind_kwargs:
|
||||||
worker.calculate_managed_tasks()
|
body['kwargs']['dispatch_time'] = tz_now().isoformat()
|
||||||
running_uuids.extend(list(worker.managed_tasks.keys()))
|
if 'worker_tasks' in bind_kwargs:
|
||||||
reaper.reap(excluded_uuids=running_uuids)
|
worker_tasks = {}
|
||||||
|
for worker in self.workers:
|
||||||
|
worker.calculate_managed_tasks()
|
||||||
|
worker_tasks[worker.pid] = list(worker.managed_tasks.keys())
|
||||||
|
body['kwargs']['worker_tasks'] = worker_tasks
|
||||||
|
|
||||||
def up(self):
|
def up(self):
|
||||||
if self.full:
|
if self.full:
|
||||||
@@ -438,6 +448,8 @@ class AutoscalePool(WorkerPool):
|
|||||||
if 'guid' in body:
|
if 'guid' in body:
|
||||||
set_guid(body['guid'])
|
set_guid(body['guid'])
|
||||||
try:
|
try:
|
||||||
|
if isinstance(body, dict) and body.get('bind_kwargs'):
|
||||||
|
self.add_bind_kwargs(body)
|
||||||
# when the cluster heartbeat occurs, clean up internally
|
# when the cluster heartbeat occurs, clean up internally
|
||||||
if isinstance(body, dict) and 'cluster_node_heartbeat' in body['task']:
|
if isinstance(body, dict) and 'cluster_node_heartbeat' in body['task']:
|
||||||
self.cleanup()
|
self.cleanup()
|
||||||
@@ -452,6 +464,10 @@ class AutoscalePool(WorkerPool):
|
|||||||
w.put(body)
|
w.put(body)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
task_name = 'unknown'
|
||||||
|
if isinstance(body, dict):
|
||||||
|
task_name = body.get('task')
|
||||||
|
logger.warn(f'Workers maxed, queuing {task_name}, load: {sum(len(w.managed_tasks) for w in self.workers)} / {len(self.workers)}')
|
||||||
return super(AutoscalePool, self).write(preferred_queue, body)
|
return super(AutoscalePool, self).write(preferred_queue, body)
|
||||||
except Exception:
|
except Exception:
|
||||||
for conn in connections.all():
|
for conn in connections.all():
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import inspect
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -49,13 +50,21 @@ class task:
|
|||||||
@task(queue='tower_broadcast')
|
@task(queue='tower_broadcast')
|
||||||
def announce():
|
def announce():
|
||||||
print("Run this everywhere!")
|
print("Run this everywhere!")
|
||||||
|
|
||||||
|
# The special parameter bind_kwargs tells the main dispatcher process to add certain kwargs
|
||||||
|
|
||||||
|
@task(bind_kwargs=['dispatch_time'])
|
||||||
|
def print_time(dispatch_time=None):
|
||||||
|
print(f"Time I was dispatched: {dispatch_time}")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, queue=None):
|
def __init__(self, queue=None, bind_kwargs=None):
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
|
self.bind_kwargs = bind_kwargs
|
||||||
|
|
||||||
def __call__(self, fn=None):
|
def __call__(self, fn=None):
|
||||||
queue = self.queue
|
queue = self.queue
|
||||||
|
bind_kwargs = self.bind_kwargs
|
||||||
|
|
||||||
class PublisherMixin(object):
|
class PublisherMixin(object):
|
||||||
|
|
||||||
@@ -75,10 +84,12 @@ class task:
|
|||||||
msg = f'{cls.name}: Queue value required and may not be None'
|
msg = f'{cls.name}: Queue value required and may not be None'
|
||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
obj = {'uuid': task_id, 'args': args, 'kwargs': kwargs, 'task': cls.name}
|
obj = {'uuid': task_id, 'args': args, 'kwargs': kwargs, 'task': cls.name, 'time_pub': time.time()}
|
||||||
guid = get_guid()
|
guid = get_guid()
|
||||||
if guid:
|
if guid:
|
||||||
obj['guid'] = guid
|
obj['guid'] = guid
|
||||||
|
if bind_kwargs:
|
||||||
|
obj['bind_kwargs'] = bind_kwargs
|
||||||
obj.update(**kw)
|
obj.update(**kw)
|
||||||
if callable(queue):
|
if callable(queue):
|
||||||
queue = queue()
|
queue = queue()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.timezone import now as tz_now
|
from django.utils.timezone import now as tz_now
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
@@ -10,28 +11,76 @@ from awx.main.models import Instance, UnifiedJob, WorkflowJob
|
|||||||
logger = logging.getLogger('awx.main.dispatch')
|
logger = logging.getLogger('awx.main.dispatch')
|
||||||
|
|
||||||
|
|
||||||
def reap_job(j, status):
|
def startup_reaping():
|
||||||
if UnifiedJob.objects.get(id=j.id).status not in ('running', 'waiting'):
|
"""
|
||||||
|
If this particular instance is starting, then we know that any running jobs are invalid
|
||||||
|
so we will reap those jobs as a special action here
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
me = Instance.objects.me()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(f'Local instance is not registered, not running startup reaper: {e}')
|
||||||
|
return
|
||||||
|
jobs = UnifiedJob.objects.filter(status='running', controller_node=me.hostname)
|
||||||
|
job_ids = []
|
||||||
|
for j in jobs:
|
||||||
|
job_ids.append(j.id)
|
||||||
|
reap_job(
|
||||||
|
j,
|
||||||
|
'failed',
|
||||||
|
job_explanation='Task was marked as running at system start up. The system must have not shut down properly, so it has been marked as failed.',
|
||||||
|
)
|
||||||
|
if job_ids:
|
||||||
|
logger.error(f'Unified jobs {job_ids} were reaped on dispatch startup')
|
||||||
|
|
||||||
|
|
||||||
|
def reap_job(j, status, job_explanation=None):
|
||||||
|
j.refresh_from_db(fields=['status', 'job_explanation'])
|
||||||
|
status_before = j.status
|
||||||
|
if status_before not in ('running', 'waiting'):
|
||||||
# just in case, don't reap jobs that aren't running
|
# just in case, don't reap jobs that aren't running
|
||||||
return
|
return
|
||||||
j.status = status
|
j.status = status
|
||||||
j.start_args = '' # blank field to remove encrypted passwords
|
j.start_args = '' # blank field to remove encrypted passwords
|
||||||
j.job_explanation += ' '.join(
|
if j.job_explanation:
|
||||||
(
|
j.job_explanation += ' ' # Separate messages for readability
|
||||||
'Task was marked as running but was not present in',
|
if job_explanation is None:
|
||||||
'the job queue, so it has been marked as failed.',
|
j.job_explanation += 'Task was marked as running but was not present in the job queue, so it has been marked as failed.'
|
||||||
)
|
else:
|
||||||
)
|
j.job_explanation += job_explanation
|
||||||
j.save(update_fields=['status', 'start_args', 'job_explanation'])
|
j.save(update_fields=['status', 'start_args', 'job_explanation'])
|
||||||
if hasattr(j, 'send_notification_templates'):
|
if hasattr(j, 'send_notification_templates'):
|
||||||
j.send_notification_templates('failed')
|
j.send_notification_templates('failed')
|
||||||
j.websocket_emit_status(status)
|
j.websocket_emit_status(status)
|
||||||
logger.error('{} is no longer running; reaping'.format(j.log_format))
|
logger.error(f'{j.log_format} is no longer {status_before}; reaping')
|
||||||
|
|
||||||
|
|
||||||
def reap(instance=None, status='failed', excluded_uuids=[]):
|
def reap_waiting(instance=None, status='failed', job_explanation=None, grace_period=None, excluded_uuids=None, ref_time=None):
|
||||||
"""
|
"""
|
||||||
Reap all jobs in waiting|running for this instance.
|
Reap all jobs in waiting for this instance.
|
||||||
|
"""
|
||||||
|
if grace_period is None:
|
||||||
|
grace_period = settings.JOB_WAITING_GRACE_PERIOD + settings.TASK_MANAGER_TIMEOUT
|
||||||
|
|
||||||
|
me = instance
|
||||||
|
if me is None:
|
||||||
|
try:
|
||||||
|
me = Instance.objects.me()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(f'Local instance is not registered, not running reaper: {e}')
|
||||||
|
return
|
||||||
|
if ref_time is None:
|
||||||
|
ref_time = tz_now()
|
||||||
|
jobs = UnifiedJob.objects.filter(status='waiting', modified__lte=ref_time - timedelta(seconds=grace_period), controller_node=me.hostname)
|
||||||
|
if excluded_uuids:
|
||||||
|
jobs = jobs.exclude(celery_task_id__in=excluded_uuids)
|
||||||
|
for j in jobs:
|
||||||
|
reap_job(j, status, job_explanation=job_explanation)
|
||||||
|
|
||||||
|
|
||||||
|
def reap(instance=None, status='failed', job_explanation=None, excluded_uuids=None):
|
||||||
|
"""
|
||||||
|
Reap all jobs in running for this instance.
|
||||||
"""
|
"""
|
||||||
me = instance
|
me = instance
|
||||||
if me is None:
|
if me is None:
|
||||||
@@ -40,12 +89,11 @@ def reap(instance=None, status='failed', excluded_uuids=[]):
|
|||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
logger.warning(f'Local instance is not registered, not running reaper: {e}')
|
logger.warning(f'Local instance is not registered, not running reaper: {e}')
|
||||||
return
|
return
|
||||||
now = tz_now()
|
|
||||||
workflow_ctype_id = ContentType.objects.get_for_model(WorkflowJob).id
|
workflow_ctype_id = ContentType.objects.get_for_model(WorkflowJob).id
|
||||||
jobs = UnifiedJob.objects.filter(
|
jobs = UnifiedJob.objects.filter(
|
||||||
(Q(status='running') | Q(status='waiting', modified__lte=now - timedelta(seconds=60)))
|
Q(status='running') & (Q(execution_node=me.hostname) | Q(controller_node=me.hostname)) & ~Q(polymorphic_ctype_id=workflow_ctype_id)
|
||||||
& (Q(execution_node=me.hostname) | Q(controller_node=me.hostname))
|
)
|
||||||
& ~Q(polymorphic_ctype_id=workflow_ctype_id)
|
if excluded_uuids:
|
||||||
).exclude(celery_task_id__in=excluded_uuids)
|
jobs = jobs.exclude(celery_task_id__in=excluded_uuids)
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
reap_job(j, status)
|
reap_job(j, status, job_explanation=job_explanation)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from awx.main.dispatch.pool import WorkerPool
|
from awx.main.dispatch.pool import WorkerPool
|
||||||
from awx.main.dispatch import pg_bus_conn
|
from awx.main.dispatch import pg_bus_conn
|
||||||
|
from awx.main.utils.common import log_excess_runtime
|
||||||
|
|
||||||
if 'run_callback_receiver' in sys.argv:
|
if 'run_callback_receiver' in sys.argv:
|
||||||
logger = logging.getLogger('awx.main.commands.run_callback_receiver')
|
logger = logging.getLogger('awx.main.commands.run_callback_receiver')
|
||||||
@@ -81,6 +82,9 @@ class AWXConsumerBase(object):
|
|||||||
logger.error('unrecognized control message: {}'.format(control))
|
logger.error('unrecognized control message: {}'.format(control))
|
||||||
|
|
||||||
def process_task(self, body):
|
def process_task(self, body):
|
||||||
|
if isinstance(body, dict):
|
||||||
|
body['time_ack'] = time.time()
|
||||||
|
|
||||||
if 'control' in body:
|
if 'control' in body:
|
||||||
try:
|
try:
|
||||||
return self.control(body)
|
return self.control(body)
|
||||||
@@ -101,6 +105,7 @@ class AWXConsumerBase(object):
|
|||||||
self.total_messages += 1
|
self.total_messages += 1
|
||||||
self.record_statistics()
|
self.record_statistics()
|
||||||
|
|
||||||
|
@log_excess_runtime(logger)
|
||||||
def record_statistics(self):
|
def record_statistics(self):
|
||||||
if time.time() - self.last_stats > 1: # buffer stat recording to once per second
|
if time.time() - self.last_stats > 1: # buffer stat recording to once per second
|
||||||
try:
|
try:
|
||||||
@@ -149,7 +154,7 @@ class AWXConsumerPG(AWXConsumerBase):
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
with pg_bus_conn() as conn:
|
with pg_bus_conn(new_connection=True) as conn:
|
||||||
for queue in self.queues:
|
for queue in self.queues:
|
||||||
conn.listen(queue)
|
conn.listen(queue)
|
||||||
if init is False:
|
if init is False:
|
||||||
@@ -169,8 +174,9 @@ class AWXConsumerPG(AWXConsumerBase):
|
|||||||
logger.exception(f"Error consuming new events from postgres, will retry for {self.pg_max_wait} s")
|
logger.exception(f"Error consuming new events from postgres, will retry for {self.pg_max_wait} s")
|
||||||
self.pg_down_time = time.time()
|
self.pg_down_time = time.time()
|
||||||
self.pg_is_down = True
|
self.pg_is_down = True
|
||||||
if time.time() - self.pg_down_time > self.pg_max_wait:
|
current_downtime = time.time() - self.pg_down_time
|
||||||
logger.warning(f"Postgres event consumer has not recovered in {self.pg_max_wait} s, exiting")
|
if current_downtime > self.pg_max_wait:
|
||||||
|
logger.exception(f"Postgres event consumer has not recovered in {current_downtime} s, exiting")
|
||||||
raise
|
raise
|
||||||
# Wait for a second before next attempt, but still listen for any shutdown signals
|
# Wait for a second before next attempt, but still listen for any shutdown signals
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
@@ -179,6 +185,10 @@ class AWXConsumerPG(AWXConsumerBase):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
for conn in db.connections.all():
|
for conn in db.connections.all():
|
||||||
conn.close_if_unusable_or_obsolete()
|
conn.close_if_unusable_or_obsolete()
|
||||||
|
except Exception:
|
||||||
|
# Log unanticipated exception in addition to writing to stderr to get timestamps and other metadata
|
||||||
|
logger.exception('Encountered unhandled error in dispatcher main loop')
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class BaseWorker(object):
|
class BaseWorker(object):
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import datetime
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import now as tz_now
|
from django.utils.timezone import now as tz_now
|
||||||
from django.db import DatabaseError, OperationalError, connection as django_connection
|
from django.db import DatabaseError, OperationalError, transaction, connection as django_connection
|
||||||
from django.db.utils import InterfaceError, InternalError
|
from django.db.utils import InterfaceError, InternalError
|
||||||
from django_guid import set_guid
|
from django_guid import set_guid
|
||||||
|
|
||||||
@@ -16,8 +18,8 @@ import psutil
|
|||||||
import redis
|
import redis
|
||||||
|
|
||||||
from awx.main.consumers import emit_channel_notification
|
from awx.main.consumers import emit_channel_notification
|
||||||
from awx.main.models import JobEvent, AdHocCommandEvent, ProjectUpdateEvent, InventoryUpdateEvent, SystemJobEvent, UnifiedJob, Job
|
from awx.main.models import JobEvent, AdHocCommandEvent, ProjectUpdateEvent, InventoryUpdateEvent, SystemJobEvent, UnifiedJob
|
||||||
from awx.main.tasks.system import handle_success_and_failure_notifications
|
from awx.main.constants import ACTIVE_STATES
|
||||||
from awx.main.models.events import emit_event_detail
|
from awx.main.models.events import emit_event_detail
|
||||||
from awx.main.utils.profiling import AWXProfiler
|
from awx.main.utils.profiling import AWXProfiler
|
||||||
import awx.main.analytics.subsystem_metrics as s_metrics
|
import awx.main.analytics.subsystem_metrics as s_metrics
|
||||||
@@ -26,6 +28,32 @@ from .base import BaseWorker
|
|||||||
logger = logging.getLogger('awx.main.commands.run_callback_receiver')
|
logger = logging.getLogger('awx.main.commands.run_callback_receiver')
|
||||||
|
|
||||||
|
|
||||||
|
def job_stats_wrapup(job_identifier, event=None):
|
||||||
|
"""Fill in the unified job host_status_counts, fire off notifications if needed"""
|
||||||
|
try:
|
||||||
|
# empty dict (versus default of None) can still indicate that events have been processed
|
||||||
|
# for job types like system jobs, and jobs with no hosts matched
|
||||||
|
host_status_counts = {}
|
||||||
|
if event:
|
||||||
|
host_status_counts = event.get_host_status_counts()
|
||||||
|
|
||||||
|
# Update host_status_counts while holding the row lock
|
||||||
|
with transaction.atomic():
|
||||||
|
uj = UnifiedJob.objects.select_for_update().get(pk=job_identifier)
|
||||||
|
uj.host_status_counts = host_status_counts
|
||||||
|
uj.save(update_fields=['host_status_counts'])
|
||||||
|
|
||||||
|
uj.log_lifecycle("stats_wrapup_finished")
|
||||||
|
|
||||||
|
# If the status was a finished state before this update was made, send notifications
|
||||||
|
# If not, we will send notifications when the status changes
|
||||||
|
if uj.status not in ACTIVE_STATES:
|
||||||
|
uj.send_notification_templates('succeeded' if uj.status == 'successful' else 'failed')
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Worker failed to save stats or emit notifications: Job {}'.format(job_identifier))
|
||||||
|
|
||||||
|
|
||||||
class CallbackBrokerWorker(BaseWorker):
|
class CallbackBrokerWorker(BaseWorker):
|
||||||
"""
|
"""
|
||||||
A worker implementation that deserializes callback event data and persists
|
A worker implementation that deserializes callback event data and persists
|
||||||
@@ -44,7 +72,6 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.buff = {}
|
self.buff = {}
|
||||||
self.pid = os.getpid()
|
|
||||||
self.redis = redis.Redis.from_url(settings.BROKER_URL)
|
self.redis = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
self.subsystem_metrics = s_metrics.Metrics(auto_pipe_execute=False)
|
self.subsystem_metrics = s_metrics.Metrics(auto_pipe_execute=False)
|
||||||
self.queue_pop = 0
|
self.queue_pop = 0
|
||||||
@@ -53,6 +80,11 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
for key in self.redis.keys('awx_callback_receiver_statistics_*'):
|
for key in self.redis.keys('awx_callback_receiver_statistics_*'):
|
||||||
self.redis.delete(key)
|
self.redis.delete(key)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def pid(self):
|
||||||
|
"""This needs to be obtained after forking, or else it will give the parent process"""
|
||||||
|
return os.getpid()
|
||||||
|
|
||||||
def read(self, queue):
|
def read(self, queue):
|
||||||
try:
|
try:
|
||||||
res = self.redis.blpop(self.queue_name, timeout=1)
|
res = self.redis.blpop(self.queue_name, timeout=1)
|
||||||
@@ -120,32 +152,49 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
metrics_singular_events_saved = 0
|
metrics_singular_events_saved = 0
|
||||||
metrics_events_batch_save_errors = 0
|
metrics_events_batch_save_errors = 0
|
||||||
metrics_events_broadcast = 0
|
metrics_events_broadcast = 0
|
||||||
|
metrics_events_missing_created = 0
|
||||||
|
metrics_total_job_event_processing_seconds = datetime.timedelta(seconds=0)
|
||||||
for cls, events in self.buff.items():
|
for cls, events in self.buff.items():
|
||||||
logger.debug(f'{cls.__name__}.objects.bulk_create({len(events)})')
|
logger.debug(f'{cls.__name__}.objects.bulk_create({len(events)})')
|
||||||
for e in events:
|
for e in events:
|
||||||
|
e.modified = now # this can be set before created because now is set above on line 149
|
||||||
if not e.created:
|
if not e.created:
|
||||||
e.created = now
|
e.created = now
|
||||||
e.modified = now
|
metrics_events_missing_created += 1
|
||||||
|
else: # only calculate the seconds if the created time already has been set
|
||||||
|
metrics_total_job_event_processing_seconds += e.modified - e.created
|
||||||
metrics_duration_to_save = time.perf_counter()
|
metrics_duration_to_save = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
cls.objects.bulk_create(events)
|
cls.objects.bulk_create(events)
|
||||||
metrics_bulk_events_saved += len(events)
|
metrics_bulk_events_saved += len(events)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.warning(f'Error in events bulk_create, will try indiviually up to 5 errors, error {str(exc)}')
|
||||||
# if an exception occurs, we should re-attempt to save the
|
# if an exception occurs, we should re-attempt to save the
|
||||||
# events one-by-one, because something in the list is
|
# events one-by-one, because something in the list is
|
||||||
# broken/stale
|
# broken/stale
|
||||||
|
consecutive_errors = 0
|
||||||
|
events_saved = 0
|
||||||
metrics_events_batch_save_errors += 1
|
metrics_events_batch_save_errors += 1
|
||||||
for e in events:
|
for e in events:
|
||||||
try:
|
try:
|
||||||
e.save()
|
e.save()
|
||||||
metrics_singular_events_saved += 1
|
events_saved += 1
|
||||||
except Exception:
|
consecutive_errors = 0
|
||||||
logger.exception('Database Error Saving Job Event')
|
except Exception as exc_indv:
|
||||||
|
consecutive_errors += 1
|
||||||
|
logger.info(f'Database Error Saving individual Job Event, error {str(exc_indv)}')
|
||||||
|
if consecutive_errors >= 5:
|
||||||
|
raise
|
||||||
|
metrics_singular_events_saved += events_saved
|
||||||
|
if events_saved == 0:
|
||||||
|
raise
|
||||||
metrics_duration_to_save = time.perf_counter() - metrics_duration_to_save
|
metrics_duration_to_save = time.perf_counter() - metrics_duration_to_save
|
||||||
for e in events:
|
for e in events:
|
||||||
if not getattr(e, '_skip_websocket_message', False):
|
if not getattr(e, '_skip_websocket_message', False):
|
||||||
metrics_events_broadcast += 1
|
metrics_events_broadcast += 1
|
||||||
emit_event_detail(e)
|
emit_event_detail(e)
|
||||||
|
if getattr(e, '_notification_trigger_event', False):
|
||||||
|
job_stats_wrapup(getattr(e, e.JOB_REFERENCE), event=e)
|
||||||
self.buff = {}
|
self.buff = {}
|
||||||
self.last_flush = time.time()
|
self.last_flush = time.time()
|
||||||
# only update metrics if we saved events
|
# only update metrics if we saved events
|
||||||
@@ -156,6 +205,11 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
self.subsystem_metrics.observe('callback_receiver_batch_events_insert_db', metrics_bulk_events_saved)
|
self.subsystem_metrics.observe('callback_receiver_batch_events_insert_db', metrics_bulk_events_saved)
|
||||||
self.subsystem_metrics.inc('callback_receiver_events_in_memory', -(metrics_bulk_events_saved + metrics_singular_events_saved))
|
self.subsystem_metrics.inc('callback_receiver_events_in_memory', -(metrics_bulk_events_saved + metrics_singular_events_saved))
|
||||||
self.subsystem_metrics.inc('callback_receiver_events_broadcast', metrics_events_broadcast)
|
self.subsystem_metrics.inc('callback_receiver_events_broadcast', metrics_events_broadcast)
|
||||||
|
self.subsystem_metrics.set(
|
||||||
|
'callback_receiver_event_processing_avg_seconds',
|
||||||
|
metrics_total_job_event_processing_seconds.total_seconds()
|
||||||
|
/ (metrics_bulk_events_saved + metrics_singular_events_saved - metrics_events_missing_created),
|
||||||
|
)
|
||||||
if self.subsystem_metrics.should_pipe_execute() is True:
|
if self.subsystem_metrics.should_pipe_execute() is True:
|
||||||
self.subsystem_metrics.pipe_execute()
|
self.subsystem_metrics.pipe_execute()
|
||||||
|
|
||||||
@@ -165,47 +219,32 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
if flush:
|
if flush:
|
||||||
self.last_event = ''
|
self.last_event = ''
|
||||||
if not flush:
|
if not flush:
|
||||||
event_map = {
|
|
||||||
'job_id': JobEvent,
|
|
||||||
'ad_hoc_command_id': AdHocCommandEvent,
|
|
||||||
'project_update_id': ProjectUpdateEvent,
|
|
||||||
'inventory_update_id': InventoryUpdateEvent,
|
|
||||||
'system_job_id': SystemJobEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
job_identifier = 'unknown job'
|
job_identifier = 'unknown job'
|
||||||
for key, cls in event_map.items():
|
for cls in (JobEvent, AdHocCommandEvent, ProjectUpdateEvent, InventoryUpdateEvent, SystemJobEvent):
|
||||||
if key in body:
|
if cls.JOB_REFERENCE in body:
|
||||||
job_identifier = body[key]
|
job_identifier = body[cls.JOB_REFERENCE]
|
||||||
break
|
break
|
||||||
|
|
||||||
self.last_event = f'\n\t- {cls.__name__} for #{job_identifier} ({body.get("event", "")} {body.get("uuid", "")})' # noqa
|
self.last_event = f'\n\t- {cls.__name__} for #{job_identifier} ({body.get("event", "")} {body.get("uuid", "")})' # noqa
|
||||||
|
|
||||||
|
notification_trigger_event = bool(body.get('event') == cls.WRAPUP_EVENT)
|
||||||
|
|
||||||
if body.get('event') == 'EOF':
|
if body.get('event') == 'EOF':
|
||||||
try:
|
try:
|
||||||
if 'guid' in body:
|
if 'guid' in body:
|
||||||
set_guid(body['guid'])
|
set_guid(body['guid'])
|
||||||
final_counter = body.get('final_counter', 0)
|
final_counter = body.get('final_counter', 0)
|
||||||
logger.info('Event processing is finished for Job {}, sending notifications'.format(job_identifier))
|
logger.info('Starting EOF event processing for Job {}'.format(job_identifier))
|
||||||
# EOF events are sent when stdout for the running task is
|
# EOF events are sent when stdout for the running task is
|
||||||
# closed. don't actually persist them to the database; we
|
# closed. don't actually persist them to the database; we
|
||||||
# just use them to report `summary` websocket events as an
|
# just use them to report `summary` websocket events as an
|
||||||
# approximation for when a job is "done"
|
# approximation for when a job is "done"
|
||||||
emit_channel_notification('jobs-summary', dict(group_name='jobs', unified_job_id=job_identifier, final_counter=final_counter))
|
emit_channel_notification('jobs-summary', dict(group_name='jobs', unified_job_id=job_identifier, final_counter=final_counter))
|
||||||
# Additionally, when we've processed all events, we should
|
|
||||||
# have all the data we need to send out success/failure
|
|
||||||
# notification templates
|
|
||||||
uj = UnifiedJob.objects.get(pk=job_identifier)
|
|
||||||
|
|
||||||
if isinstance(uj, Job):
|
if notification_trigger_event:
|
||||||
# *actual playbooks* send their success/failure
|
job_stats_wrapup(job_identifier)
|
||||||
# notifications in response to the playbook_on_stats
|
|
||||||
# event handling code in main.models.events
|
|
||||||
pass
|
|
||||||
elif hasattr(uj, 'send_notification_templates'):
|
|
||||||
handle_success_and_failure_notifications.apply_async([uj.id])
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Worker failed to emit notifications: Job {}'.format(job_identifier))
|
logger.exception('Worker failed to perform EOF tasks: Job {}'.format(job_identifier))
|
||||||
finally:
|
finally:
|
||||||
self.subsystem_metrics.inc('callback_receiver_events_in_memory', -1)
|
self.subsystem_metrics.inc('callback_receiver_events_in_memory', -1)
|
||||||
set_guid('')
|
set_guid('')
|
||||||
@@ -215,9 +254,12 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
|
|
||||||
event = cls.create_from_data(**body)
|
event = cls.create_from_data(**body)
|
||||||
|
|
||||||
if skip_websocket_message:
|
if skip_websocket_message: # if this event sends websocket messages, fire them off on flush
|
||||||
event._skip_websocket_message = True
|
event._skip_websocket_message = True
|
||||||
|
|
||||||
|
if notification_trigger_event: # if this is an Ansible stats event, ensure notifications on flush
|
||||||
|
event._notification_trigger_event = True
|
||||||
|
|
||||||
self.buff.setdefault(cls, []).append(event)
|
self.buff.setdefault(cls, []).append(event)
|
||||||
|
|
||||||
retries = 0
|
retries = 0
|
||||||
@@ -225,17 +267,18 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
try:
|
try:
|
||||||
self.flush(force=flush)
|
self.flush(force=flush)
|
||||||
break
|
break
|
||||||
except (OperationalError, InterfaceError, InternalError):
|
except (OperationalError, InterfaceError, InternalError) as exc:
|
||||||
if retries >= self.MAX_RETRIES:
|
if retries >= self.MAX_RETRIES:
|
||||||
logger.exception('Worker could not re-establish database connectivity, giving up on one or more events.')
|
logger.exception('Worker could not re-establish database connectivity, giving up on one or more events.')
|
||||||
return
|
return
|
||||||
delay = 60 * retries
|
delay = 60 * retries
|
||||||
logger.exception('Database Error Saving Job Event, retry #{i} in {delay} seconds:'.format(i=retries + 1, delay=delay))
|
logger.warning(f'Database Error Flushing Job Events, retry #{retries + 1} in {delay} seconds: {str(exc)}')
|
||||||
django_connection.close()
|
django_connection.close()
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
retries += 1
|
retries += 1
|
||||||
except DatabaseError:
|
except DatabaseError:
|
||||||
logger.exception('Database Error Saving Job Event')
|
logger.exception('Database Error Flushing Job Events')
|
||||||
|
django_connection.close()
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
tb = traceback.format_exc()
|
tb = traceback.format_exc()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import importlib
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
import time
|
||||||
|
|
||||||
from kubernetes.config import kube_config
|
from kubernetes.config import kube_config
|
||||||
|
|
||||||
@@ -60,8 +61,19 @@ class TaskWorker(BaseWorker):
|
|||||||
# the callable is a class, e.g., RunJob; instantiate and
|
# the callable is a class, e.g., RunJob; instantiate and
|
||||||
# return its `run()` method
|
# return its `run()` method
|
||||||
_call = _call().run
|
_call = _call().run
|
||||||
|
|
||||||
|
log_extra = ''
|
||||||
|
logger_method = logger.debug
|
||||||
|
if ('time_ack' in body) and ('time_pub' in body):
|
||||||
|
time_publish = body['time_ack'] - body['time_pub']
|
||||||
|
time_waiting = time.time() - body['time_ack']
|
||||||
|
if time_waiting > 5.0 or time_publish > 5.0:
|
||||||
|
# If task too a very long time to process, add this information to the log
|
||||||
|
log_extra = f' took {time_publish:.4f} to ack, {time_waiting:.4f} in local dispatcher'
|
||||||
|
logger_method = logger.info
|
||||||
# don't print kwargs, they often contain launch-time secrets
|
# don't print kwargs, they often contain launch-time secrets
|
||||||
logger.debug('task {} starting {}(*{})'.format(uuid, task, args))
|
logger_method(f'task {uuid} starting {task}(*{args}){log_extra}')
|
||||||
|
|
||||||
return _call(*args, **kwargs)
|
return _call(*args, **kwargs)
|
||||||
|
|
||||||
def perform_work(self, body):
|
def perform_work(self, body):
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class DeleteMeta:
|
|||||||
|
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
query = "SELECT inhrelid::regclass::text AS child FROM pg_catalog.pg_inherits"
|
query = "SELECT inhrelid::regclass::text AS child FROM pg_catalog.pg_inherits"
|
||||||
query += f" WHERE inhparent = 'public.{tbl_name}'::regclass"
|
query += f" WHERE inhparent = '{tbl_name}'::regclass"
|
||||||
query += f" AND TO_TIMESTAMP(LTRIM(inhrelid::regclass::text, '{tbl_name}_'), 'YYYYMMDD_HH24') < '{self.cutoff}'"
|
query += f" AND TO_TIMESTAMP(LTRIM(inhrelid::regclass::text, '{tbl_name}_'), 'YYYYMMDD_HH24') < '{self.cutoff}'"
|
||||||
query += " ORDER BY inhrelid::regclass::text"
|
query += " ORDER BY inhrelid::regclass::text"
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ class Command(BaseCommand):
|
|||||||
name='Demo Project',
|
name='Demo Project',
|
||||||
scm_type='git',
|
scm_type='git',
|
||||||
scm_url='https://github.com/ansible/ansible-tower-samples',
|
scm_url='https://github.com/ansible/ansible-tower-samples',
|
||||||
scm_update_on_launch=True,
|
|
||||||
scm_update_cache_timeout=0,
|
scm_update_cache_timeout=0,
|
||||||
|
status='successful',
|
||||||
|
scm_revision='347e44fea036c94d5f60e544de006453ee5c71ad',
|
||||||
|
playbook_files=['hello_world.yml'],
|
||||||
)
|
)
|
||||||
|
|
||||||
p.organization = o
|
p.organization = o
|
||||||
|
|||||||
@@ -862,7 +862,7 @@ class Command(BaseCommand):
|
|||||||
overwrite_vars=bool(options.get('overwrite_vars', False)),
|
overwrite_vars=bool(options.get('overwrite_vars', False)),
|
||||||
)
|
)
|
||||||
inventory_update = inventory_source.create_inventory_update(
|
inventory_update = inventory_source.create_inventory_update(
|
||||||
_eager_fields=dict(job_args=json.dumps(sys.argv), job_env=dict(os.environ.items()), job_cwd=os.getcwd())
|
_eager_fields=dict(status='running', job_args=json.dumps(sys.argv), job_env=dict(os.environ.items()), job_cwd=os.getcwd())
|
||||||
)
|
)
|
||||||
|
|
||||||
data = AnsibleInventoryLoader(source=source, verbosity=verbosity).load()
|
data = AnsibleInventoryLoader(source=source, verbosity=verbosity).load()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from django.core.cache import cache as django_cache
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection as django_connection
|
from django.db import connection as django_connection
|
||||||
|
|
||||||
from awx.main.dispatch import get_local_queuename, reaper
|
from awx.main.dispatch import get_local_queuename
|
||||||
from awx.main.dispatch.control import Control
|
from awx.main.dispatch.control import Control
|
||||||
from awx.main.dispatch.pool import AutoscalePool
|
from awx.main.dispatch.pool import AutoscalePool
|
||||||
from awx.main.dispatch.worker import AWXConsumerPG, TaskWorker
|
from awx.main.dispatch.worker import AWXConsumerPG, TaskWorker
|
||||||
@@ -53,7 +53,6 @@ class Command(BaseCommand):
|
|||||||
# (like the node heartbeat)
|
# (like the node heartbeat)
|
||||||
periodic.run_continuously()
|
periodic.run_continuously()
|
||||||
|
|
||||||
reaper.reap()
|
|
||||||
consumer = None
|
consumer = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -95,8 +95,13 @@ class Command(BaseCommand):
|
|||||||
# database migrations are still running
|
# database migrations are still running
|
||||||
from awx.main.models.ha import Instance
|
from awx.main.models.ha import Instance
|
||||||
|
|
||||||
executor = MigrationExecutor(connection)
|
try:
|
||||||
migrating = bool(executor.migration_plan(executor.loader.graph.leaf_nodes()))
|
executor = MigrationExecutor(connection)
|
||||||
|
migrating = bool(executor.migration_plan(executor.loader.graph.leaf_nodes()))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info(f'Error on startup of run_wsbroadcast (error: {exc}), retry in 10s...')
|
||||||
|
time.sleep(10)
|
||||||
|
return
|
||||||
|
|
||||||
# In containerized deployments, migrations happen in the task container,
|
# In containerized deployments, migrations happen in the task container,
|
||||||
# and the services running there don't start until migrations are
|
# and the services running there don't start until migrations are
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ logger = logging.getLogger('awx.main.middleware')
|
|||||||
perf_logger = logging.getLogger('awx.analytics.performance')
|
perf_logger = logging.getLogger('awx.analytics.performance')
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsCacheMiddleware(MiddlewareMixin):
|
||||||
|
"""
|
||||||
|
Clears the in-memory settings cache at the beginning of a request.
|
||||||
|
We do this so that a script can POST to /api/v2/settings/all/ and then
|
||||||
|
right away GET /api/v2/settings/all/ and see the updated value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process_request(self, request):
|
||||||
|
settings._awx_conf_memoizedcache.clear()
|
||||||
|
|
||||||
|
|
||||||
class TimingMiddleware(threading.local, MiddlewareMixin):
|
class TimingMiddleware(threading.local, MiddlewareMixin):
|
||||||
|
|
||||||
dest = '/var/log/tower/profile'
|
dest = '/var/log/tower/profile'
|
||||||
|
|||||||
18
awx/main/migrations/0160_alter_schedule_rrule.py
Normal file
18
awx/main/migrations/0160_alter_schedule_rrule.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.12 on 2022-04-18 21:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0159_deprecate_inventory_source_UoPU_field'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='schedule',
|
||||||
|
name='rrule',
|
||||||
|
field=models.TextField(help_text='A value representing the schedules iCal recurrence rule.'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
awx/main/migrations/0161_unifiedjob_host_status_counts.py
Normal file
18
awx/main/migrations/0161_unifiedjob_host_status_counts.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.12 on 2022-04-27 02:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0160_alter_schedule_rrule'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unifiedjob',
|
||||||
|
name='host_status_counts',
|
||||||
|
field=models.JSONField(blank=True, default=None, editable=False, help_text='Playbook stats from the Ansible playbook_on_stats event.', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
awx/main/migrations/0162_alter_unifiedjob_dependent_jobs.py
Normal file
18
awx/main/migrations/0162_alter_unifiedjob_dependent_jobs.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-05-02 21:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0161_unifiedjob_host_status_counts'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='unifiedjob',
|
||||||
|
name='dependent_jobs',
|
||||||
|
field=models.ManyToManyField(editable=False, related_name='unifiedjob_blocked_jobs', to='main.UnifiedJob'),
|
||||||
|
),
|
||||||
|
]
|
||||||
23
awx/main/migrations/0163_convert_job_tags_to_textfield.py
Normal file
23
awx/main/migrations/0163_convert_job_tags_to_textfield.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-02 18:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0162_alter_unifiedjob_dependent_jobs'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='job',
|
||||||
|
name='job_tags',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='jobtemplate',
|
||||||
|
name='job_tags',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-21 21:29
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("awx")
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
InventorySource = apps.get_model('main', 'InventorySource')
|
||||||
|
sources = InventorySource.objects.filter(update_on_project_update=True)
|
||||||
|
for src in sources:
|
||||||
|
if src.update_on_launch == False:
|
||||||
|
src.update_on_launch = True
|
||||||
|
src.save(update_fields=['update_on_launch'])
|
||||||
|
logger.info(f"Setting update_on_launch to True for {src}")
|
||||||
|
proj = src.source_project
|
||||||
|
if proj and proj.scm_update_on_launch is False:
|
||||||
|
proj.scm_update_on_launch = True
|
||||||
|
proj.save(update_fields=['scm_update_on_launch'])
|
||||||
|
logger.warning(f"Setting scm_update_on_launch to True for {proj}")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0163_convert_job_tags_to_textfield'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='inventorysource',
|
||||||
|
name='scm_last_revision',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='inventorysource',
|
||||||
|
name='update_on_project_update',
|
||||||
|
),
|
||||||
|
]
|
||||||
35
awx/main/migrations/0165_task_manager_refactor.py
Normal file
35
awx/main/migrations/0165_task_manager_refactor.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-08-10 14:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0164_remove_inventorysource_update_on_project_update'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unifiedjob',
|
||||||
|
name='preferred_instance_groups_cache',
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True, default=None, editable=False, help_text='A cached list with pk values from preferred instance groups.', null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unifiedjob',
|
||||||
|
name='task_impact',
|
||||||
|
field=models.PositiveIntegerField(default=0, editable=False, help_text='Number of forks an instance consumes when running this job.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workflowapproval',
|
||||||
|
name='expires',
|
||||||
|
field=models.DateTimeField(
|
||||||
|
default=None,
|
||||||
|
editable=False,
|
||||||
|
help_text='The time this approval will expire. This is the created time plus timeout, used for filtering.',
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
40
awx/main/migrations/0166_alter_jobevent_host.py
Normal file
40
awx/main/migrations/0166_alter_jobevent_host.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-07-06 13:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0165_task_manager_refactor'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='adhoccommandevent',
|
||||||
|
name='host',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
default=None,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='ad_hoc_command_events',
|
||||||
|
to='main.host',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='jobevent',
|
||||||
|
name='host',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
default=None,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name='job_events_as_primary_host',
|
||||||
|
to='main.host',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -36,7 +36,7 @@ def create_clearsessions_jt(apps, schema_editor):
|
|||||||
if created:
|
if created:
|
||||||
sched = Schedule(
|
sched = Schedule(
|
||||||
name='Cleanup Expired Sessions',
|
name='Cleanup Expired Sessions',
|
||||||
rrule='DTSTART:%s RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1' % schedule_time,
|
rrule='DTSTART:%s RRULE:FREQ=WEEKLY;INTERVAL=1' % schedule_time,
|
||||||
description='Cleans out expired browser sessions',
|
description='Cleans out expired browser sessions',
|
||||||
enabled=True,
|
enabled=True,
|
||||||
created=now_dt,
|
created=now_dt,
|
||||||
@@ -69,7 +69,7 @@ def create_cleartokens_jt(apps, schema_editor):
|
|||||||
if created:
|
if created:
|
||||||
sched = Schedule(
|
sched = Schedule(
|
||||||
name='Cleanup Expired OAuth 2 Tokens',
|
name='Cleanup Expired OAuth 2 Tokens',
|
||||||
rrule='DTSTART:%s RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=1' % schedule_time,
|
rrule='DTSTART:%s RRULE:FREQ=WEEKLY;INTERVAL=1' % schedule_time,
|
||||||
description='Removes expired OAuth 2 access and refresh tokens',
|
description='Removes expired OAuth 2 access and refresh tokens',
|
||||||
enabled=True,
|
enabled=True,
|
||||||
created=now_dt,
|
created=now_dt,
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin):
|
|||||||
|
|
||||||
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
||||||
|
|
||||||
|
def _set_default_dependencies_processed(self):
|
||||||
|
self.dependencies_processed = True
|
||||||
|
|
||||||
def clean_inventory(self):
|
def clean_inventory(self):
|
||||||
inv = self.inventory
|
inv = self.inventory
|
||||||
if not inv:
|
if not inv:
|
||||||
@@ -178,12 +181,12 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin):
|
|||||||
def get_passwords_needed_to_start(self):
|
def get_passwords_needed_to_start(self):
|
||||||
return self.passwords_needed_to_start
|
return self.passwords_needed_to_start
|
||||||
|
|
||||||
@property
|
def _get_task_impact(self):
|
||||||
def task_impact(self):
|
|
||||||
# NOTE: We sorta have to assume the host count matches and that forks default to 5
|
# NOTE: We sorta have to assume the host count matches and that forks default to 5
|
||||||
from awx.main.models.inventory import Host
|
if self.inventory:
|
||||||
|
count_hosts = self.inventory.total_hosts
|
||||||
count_hosts = Host.objects.filter(enabled=True, inventory__ad_hoc_commands__pk=self.pk).count()
|
else:
|
||||||
|
count_hosts = 5
|
||||||
return min(count_hosts, 5 if self.forks == 0 else self.forks) + 1
|
return min(count_hosts, 5 if self.forks == 0 else self.forks) + 1
|
||||||
|
|
||||||
def copy(self):
|
def copy(self):
|
||||||
@@ -207,10 +210,20 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
update_fields = kwargs.get('update_fields', [])
|
update_fields = kwargs.get('update_fields', [])
|
||||||
|
|
||||||
|
def add_to_update_fields(name):
|
||||||
|
if name not in update_fields:
|
||||||
|
update_fields.append(name)
|
||||||
|
|
||||||
|
if not self.preferred_instance_groups_cache:
|
||||||
|
self.preferred_instance_groups_cache = self._get_preferred_instance_group_cache()
|
||||||
|
add_to_update_fields("preferred_instance_groups_cache")
|
||||||
if not self.name:
|
if not self.name:
|
||||||
self.name = Truncator(u': '.join(filter(None, (self.module_name, self.module_args)))).chars(512)
|
self.name = Truncator(u': '.join(filter(None, (self.module_name, self.module_args)))).chars(512)
|
||||||
if 'name' not in update_fields:
|
add_to_update_fields("name")
|
||||||
update_fields.append('name')
|
if self.task_impact == 0:
|
||||||
|
self.task_impact = self._get_task_impact()
|
||||||
|
add_to_update_fields("task_impact")
|
||||||
super(AdHocCommand, self).save(*args, **kwargs)
|
super(AdHocCommand, self).save(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -316,16 +316,17 @@ class PrimordialModel(HasEditsMixin, CreatedModifiedModel):
|
|||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
if user and not user.id:
|
if user and not user.id:
|
||||||
user = None
|
user = None
|
||||||
if not self.pk and not self.created_by:
|
if (not self.pk) and (user is not None) and (not self.created_by):
|
||||||
self.created_by = user
|
self.created_by = user
|
||||||
if 'created_by' not in update_fields:
|
if 'created_by' not in update_fields:
|
||||||
update_fields.append('created_by')
|
update_fields.append('created_by')
|
||||||
# Update modified_by if any editable fields have changed
|
# Update modified_by if any editable fields have changed
|
||||||
new_values = self._get_fields_snapshot()
|
new_values = self._get_fields_snapshot()
|
||||||
if (not self.pk and not self.modified_by) or self._values_have_edits(new_values):
|
if (not self.pk and not self.modified_by) or self._values_have_edits(new_values):
|
||||||
self.modified_by = user
|
if self.modified_by != user:
|
||||||
if 'modified_by' not in update_fields:
|
self.modified_by = user
|
||||||
update_fields.append('modified_by')
|
if 'modified_by' not in update_fields:
|
||||||
|
update_fields.append('modified_by')
|
||||||
super(PrimordialModel, self).save(*args, **kwargs)
|
super(PrimordialModel, self).save(*args, **kwargs)
|
||||||
self._prior_values_store = new_values
|
self._prior_values_store = new_values
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def gce(cred, env, private_data_dir):
|
|||||||
container_path = to_container_path(path, private_data_dir)
|
container_path = to_container_path(path, private_data_dir)
|
||||||
env['GCE_CREDENTIALS_FILE_PATH'] = container_path
|
env['GCE_CREDENTIALS_FILE_PATH'] = container_path
|
||||||
env['GCP_SERVICE_ACCOUNT_FILE'] = container_path
|
env['GCP_SERVICE_ACCOUNT_FILE'] = container_path
|
||||||
|
env['GOOGLE_APPLICATION_CREDENTIALS'] = container_path
|
||||||
|
|
||||||
# Handle env variables for new module types.
|
# Handle env variables for new module types.
|
||||||
# This includes gcp_compute inventory plugin and
|
# This includes gcp_compute inventory plugin and
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections import defaultdict
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.db import models, DatabaseError, connection
|
from django.db import models, DatabaseError
|
||||||
from django.utils.dateparse import parse_datetime
|
from django.utils.dateparse import parse_datetime
|
||||||
from django.utils.text import Truncator
|
from django.utils.text import Truncator
|
||||||
from django.utils.timezone import utc, now
|
from django.utils.timezone import utc, now
|
||||||
@@ -25,7 +25,6 @@ analytics_logger = logging.getLogger('awx.analytics.job_events')
|
|||||||
|
|
||||||
logger = logging.getLogger('awx.main.models.events')
|
logger = logging.getLogger('awx.main.models.events')
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['JobEvent', 'ProjectUpdateEvent', 'AdHocCommandEvent', 'InventoryUpdateEvent', 'SystemJobEvent']
|
__all__ = ['JobEvent', 'ProjectUpdateEvent', 'AdHocCommandEvent', 'InventoryUpdateEvent', 'SystemJobEvent']
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +125,7 @@ class BasePlaybookEvent(CreatedModifiedModel):
|
|||||||
'host_name',
|
'host_name',
|
||||||
'verbosity',
|
'verbosity',
|
||||||
]
|
]
|
||||||
|
WRAPUP_EVENT = 'playbook_on_stats'
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@@ -384,14 +384,6 @@ class BasePlaybookEvent(CreatedModifiedModel):
|
|||||||
job.get_event_queryset().filter(uuid__in=changed).update(changed=True)
|
job.get_event_queryset().filter(uuid__in=changed).update(changed=True)
|
||||||
job.get_event_queryset().filter(uuid__in=failed).update(failed=True)
|
job.get_event_queryset().filter(uuid__in=failed).update(failed=True)
|
||||||
|
|
||||||
# send success/failure notifications when we've finished handling the playbook_on_stats event
|
|
||||||
from awx.main.tasks.system import handle_success_and_failure_notifications # circular import
|
|
||||||
|
|
||||||
def _send_notifications():
|
|
||||||
handle_success_and_failure_notifications.apply_async([job.id])
|
|
||||||
|
|
||||||
connection.on_commit(_send_notifications)
|
|
||||||
|
|
||||||
for field in ('playbook', 'play', 'task', 'role'):
|
for field in ('playbook', 'play', 'task', 'role'):
|
||||||
value = force_str(event_data.get(field, '')).strip()
|
value = force_str(event_data.get(field, '')).strip()
|
||||||
if value != getattr(self, field):
|
if value != getattr(self, field):
|
||||||
@@ -470,6 +462,7 @@ class JobEvent(BasePlaybookEvent):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['job_id', 'workflow_job_id', 'job_created']
|
VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['job_id', 'workflow_job_id', 'job_created']
|
||||||
|
JOB_REFERENCE = 'job_id'
|
||||||
|
|
||||||
objects = DeferJobCreatedManager()
|
objects = DeferJobCreatedManager()
|
||||||
|
|
||||||
@@ -492,13 +485,18 @@ class JobEvent(BasePlaybookEvent):
|
|||||||
editable=False,
|
editable=False,
|
||||||
db_index=False,
|
db_index=False,
|
||||||
)
|
)
|
||||||
|
# When we partitioned the table we accidentally "lost" the foreign key constraint.
|
||||||
|
# However this is good because the cascade on delete at the django layer was causing DB issues
|
||||||
|
# We are going to leave this as a foreign key but mark it as not having a DB relation and
|
||||||
|
# prevent cascading on delete.
|
||||||
host = models.ForeignKey(
|
host = models.ForeignKey(
|
||||||
'Host',
|
'Host',
|
||||||
related_name='job_events_as_primary_host',
|
related_name='job_events_as_primary_host',
|
||||||
null=True,
|
null=True,
|
||||||
default=None,
|
default=None,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.DO_NOTHING,
|
||||||
editable=False,
|
editable=False,
|
||||||
|
db_constraint=False,
|
||||||
)
|
)
|
||||||
host_name = models.CharField(
|
host_name = models.CharField(
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
@@ -600,6 +598,7 @@ UnpartitionedJobEvent._meta.db_table = '_unpartitioned_' + JobEvent._meta.db_tab
|
|||||||
class ProjectUpdateEvent(BasePlaybookEvent):
|
class ProjectUpdateEvent(BasePlaybookEvent):
|
||||||
|
|
||||||
VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['project_update_id', 'workflow_job_id', 'job_created']
|
VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['project_update_id', 'workflow_job_id', 'job_created']
|
||||||
|
JOB_REFERENCE = 'project_update_id'
|
||||||
|
|
||||||
objects = DeferJobCreatedManager()
|
objects = DeferJobCreatedManager()
|
||||||
|
|
||||||
@@ -641,6 +640,7 @@ class BaseCommandEvent(CreatedModifiedModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
VALID_KEYS = ['event_data', 'created', 'counter', 'uuid', 'stdout', 'start_line', 'end_line', 'verbosity']
|
VALID_KEYS = ['event_data', 'created', 'counter', 'uuid', 'stdout', 'start_line', 'end_line', 'verbosity']
|
||||||
|
WRAPUP_EVENT = 'EOF'
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@@ -736,6 +736,8 @@ class BaseCommandEvent(CreatedModifiedModel):
|
|||||||
class AdHocCommandEvent(BaseCommandEvent):
|
class AdHocCommandEvent(BaseCommandEvent):
|
||||||
|
|
||||||
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['ad_hoc_command_id', 'event', 'host_name', 'host_id', 'workflow_job_id', 'job_created']
|
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['ad_hoc_command_id', 'event', 'host_name', 'host_id', 'workflow_job_id', 'job_created']
|
||||||
|
WRAPUP_EVENT = 'playbook_on_stats' # exception to BaseCommandEvent
|
||||||
|
JOB_REFERENCE = 'ad_hoc_command_id'
|
||||||
|
|
||||||
objects = DeferJobCreatedManager()
|
objects = DeferJobCreatedManager()
|
||||||
|
|
||||||
@@ -796,6 +798,10 @@ class AdHocCommandEvent(BaseCommandEvent):
|
|||||||
editable=False,
|
editable=False,
|
||||||
db_index=False,
|
db_index=False,
|
||||||
)
|
)
|
||||||
|
# We need to keep this as a FK in the model because AdHocCommand uses a ManyToMany field
|
||||||
|
# to hosts through adhoc_events. But in https://github.com/ansible/awx/pull/8236/ we
|
||||||
|
# removed the nulling of the field in case of a host going away before an event is saved
|
||||||
|
# so this needs to stay SET_NULL on the ORM level
|
||||||
host = models.ForeignKey(
|
host = models.ForeignKey(
|
||||||
'Host',
|
'Host',
|
||||||
related_name='ad_hoc_command_events',
|
related_name='ad_hoc_command_events',
|
||||||
@@ -803,6 +809,7 @@ class AdHocCommandEvent(BaseCommandEvent):
|
|||||||
default=None,
|
default=None,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
editable=False,
|
editable=False,
|
||||||
|
db_constraint=False,
|
||||||
)
|
)
|
||||||
host_name = models.CharField(
|
host_name = models.CharField(
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
@@ -836,6 +843,7 @@ UnpartitionedAdHocCommandEvent._meta.db_table = '_unpartitioned_' + AdHocCommand
|
|||||||
class InventoryUpdateEvent(BaseCommandEvent):
|
class InventoryUpdateEvent(BaseCommandEvent):
|
||||||
|
|
||||||
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['inventory_update_id', 'workflow_job_id', 'job_created']
|
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['inventory_update_id', 'workflow_job_id', 'job_created']
|
||||||
|
JOB_REFERENCE = 'inventory_update_id'
|
||||||
|
|
||||||
objects = DeferJobCreatedManager()
|
objects = DeferJobCreatedManager()
|
||||||
|
|
||||||
@@ -881,6 +889,7 @@ UnpartitionedInventoryUpdateEvent._meta.db_table = '_unpartitioned_' + Inventory
|
|||||||
class SystemJobEvent(BaseCommandEvent):
|
class SystemJobEvent(BaseCommandEvent):
|
||||||
|
|
||||||
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['system_job_id', 'job_created']
|
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['system_job_id', 'job_created']
|
||||||
|
JOB_REFERENCE = 'system_job_id'
|
||||||
|
|
||||||
objects = DeferJobCreatedManager()
|
objects = DeferJobCreatedManager()
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from django.dispatch import receiver
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import now, timedelta
|
from django.utils.timezone import now, timedelta
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
from solo.models import SingletonModel
|
from solo.models import SingletonModel
|
||||||
@@ -149,10 +150,13 @@ class Instance(HasPolicyEditsMixin, BaseModel):
|
|||||||
def consumed_capacity(self):
|
def consumed_capacity(self):
|
||||||
capacity_consumed = 0
|
capacity_consumed = 0
|
||||||
if self.node_type in ('hybrid', 'execution'):
|
if self.node_type in ('hybrid', 'execution'):
|
||||||
capacity_consumed += sum(x.task_impact for x in UnifiedJob.objects.filter(execution_node=self.hostname, status__in=('running', 'waiting')))
|
capacity_consumed += (
|
||||||
|
UnifiedJob.objects.filter(execution_node=self.hostname, status__in=('running', 'waiting')).aggregate(Sum("task_impact"))["task_impact__sum"]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
if self.node_type in ('hybrid', 'control'):
|
if self.node_type in ('hybrid', 'control'):
|
||||||
capacity_consumed += sum(
|
capacity_consumed += (
|
||||||
settings.AWX_CONTROL_NODE_TASK_IMPACT for x in UnifiedJob.objects.filter(controller_node=self.hostname, status__in=('running', 'waiting'))
|
settings.AWX_CONTROL_NODE_TASK_IMPACT * UnifiedJob.objects.filter(controller_node=self.hostname, status__in=('running', 'waiting')).count()
|
||||||
)
|
)
|
||||||
return capacity_consumed
|
return capacity_consumed
|
||||||
|
|
||||||
@@ -203,7 +207,7 @@ class Instance(HasPolicyEditsMixin, BaseModel):
|
|||||||
return True
|
return True
|
||||||
if ref_time is None:
|
if ref_time is None:
|
||||||
ref_time = now()
|
ref_time = now()
|
||||||
grace_period = settings.CLUSTER_NODE_HEARTBEAT_PERIOD * 2
|
grace_period = settings.CLUSTER_NODE_HEARTBEAT_PERIOD * settings.CLUSTER_NODE_MISSED_HEARTBEAT_TOLERANCE
|
||||||
if self.node_type in ('execution', 'hop'):
|
if self.node_type in ('execution', 'hop'):
|
||||||
grace_period += settings.RECEPTOR_SERVICE_ADVERTISEMENT_PERIOD
|
grace_period += settings.RECEPTOR_SERVICE_ADVERTISEMENT_PERIOD
|
||||||
return self.last_seen < ref_time - timedelta(seconds=grace_period)
|
return self.last_seen < ref_time - timedelta(seconds=grace_period)
|
||||||
|
|||||||
@@ -236,6 +236,12 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
|
|||||||
raise ParseError(_('Slice number must be 1 or higher.'))
|
raise ParseError(_('Slice number must be 1 or higher.'))
|
||||||
return (number, step)
|
return (number, step)
|
||||||
|
|
||||||
|
def get_sliced_hosts(self, host_queryset, slice_number, slice_count):
|
||||||
|
if slice_count > 1 and slice_number > 0:
|
||||||
|
offset = slice_number - 1
|
||||||
|
host_queryset = host_queryset[offset::slice_count]
|
||||||
|
return host_queryset
|
||||||
|
|
||||||
def get_script_data(self, hostvars=False, towervars=False, show_all=False, slice_number=1, slice_count=1):
|
def get_script_data(self, hostvars=False, towervars=False, show_all=False, slice_number=1, slice_count=1):
|
||||||
hosts_kw = dict()
|
hosts_kw = dict()
|
||||||
if not show_all:
|
if not show_all:
|
||||||
@@ -243,10 +249,8 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
|
|||||||
fetch_fields = ['name', 'id', 'variables', 'inventory_id']
|
fetch_fields = ['name', 'id', 'variables', 'inventory_id']
|
||||||
if towervars:
|
if towervars:
|
||||||
fetch_fields.append('enabled')
|
fetch_fields.append('enabled')
|
||||||
hosts = self.hosts.filter(**hosts_kw).order_by('name').only(*fetch_fields)
|
host_queryset = self.hosts.filter(**hosts_kw).order_by('name').only(*fetch_fields)
|
||||||
if slice_count > 1 and slice_number > 0:
|
hosts = self.get_sliced_hosts(host_queryset, slice_number, slice_count)
|
||||||
offset = slice_number - 1
|
|
||||||
hosts = hosts[offset::slice_count]
|
|
||||||
|
|
||||||
data = dict()
|
data = dict()
|
||||||
all_group = data.setdefault('all', dict())
|
all_group = data.setdefault('all', dict())
|
||||||
@@ -337,9 +341,12 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
|
|||||||
else:
|
else:
|
||||||
active_inventory_sources = self.inventory_sources.filter(source__in=CLOUD_INVENTORY_SOURCES)
|
active_inventory_sources = self.inventory_sources.filter(source__in=CLOUD_INVENTORY_SOURCES)
|
||||||
failed_inventory_sources = active_inventory_sources.filter(last_job_failed=True)
|
failed_inventory_sources = active_inventory_sources.filter(last_job_failed=True)
|
||||||
|
total_hosts = active_hosts.count()
|
||||||
|
# if total_hosts has changed, set update_task_impact to True
|
||||||
|
update_task_impact = total_hosts != self.total_hosts
|
||||||
computed_fields = {
|
computed_fields = {
|
||||||
'has_active_failures': bool(failed_hosts.count()),
|
'has_active_failures': bool(failed_hosts.count()),
|
||||||
'total_hosts': active_hosts.count(),
|
'total_hosts': total_hosts,
|
||||||
'hosts_with_active_failures': failed_hosts.count(),
|
'hosts_with_active_failures': failed_hosts.count(),
|
||||||
'total_groups': active_groups.count(),
|
'total_groups': active_groups.count(),
|
||||||
'has_inventory_sources': bool(active_inventory_sources.count()),
|
'has_inventory_sources': bool(active_inventory_sources.count()),
|
||||||
@@ -357,6 +364,14 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
|
|||||||
computed_fields.pop(field)
|
computed_fields.pop(field)
|
||||||
if computed_fields:
|
if computed_fields:
|
||||||
iobj.save(update_fields=computed_fields.keys())
|
iobj.save(update_fields=computed_fields.keys())
|
||||||
|
if update_task_impact:
|
||||||
|
# if total hosts count has changed, re-calculate task_impact for any
|
||||||
|
# job that is still in pending for this inventory, since task_impact
|
||||||
|
# is cached on task creation and used in task management system
|
||||||
|
tasks = self.jobs.filter(status="pending")
|
||||||
|
for t in tasks:
|
||||||
|
t.task_impact = t._get_task_impact()
|
||||||
|
UnifiedJob.objects.bulk_update(tasks, ['task_impact'])
|
||||||
logger.debug("Finished updating inventory computed fields, pk={0}, in " "{1:.3f} seconds".format(self.pk, time.time() - start_time))
|
logger.debug("Finished updating inventory computed fields, pk={0}, in " "{1:.3f} seconds".format(self.pk, time.time() - start_time))
|
||||||
|
|
||||||
def websocket_emit_status(self, status):
|
def websocket_emit_status(self, status):
|
||||||
@@ -985,22 +1000,11 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
scm_last_revision = models.CharField(
|
|
||||||
max_length=1024,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
editable=False,
|
|
||||||
)
|
|
||||||
update_on_project_update = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text=_(
|
|
||||||
'This field is deprecated and will be removed in a future release. '
|
|
||||||
'In future release, functionality will be migrated to source project update_on_launch.'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
update_on_launch = models.BooleanField(
|
update_on_launch = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
update_cache_timeout = models.PositiveIntegerField(
|
update_cache_timeout = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
)
|
)
|
||||||
@@ -1038,14 +1042,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
self.name = 'inventory source (%s)' % replace_text
|
self.name = 'inventory source (%s)' % replace_text
|
||||||
if 'name' not in update_fields:
|
if 'name' not in update_fields:
|
||||||
update_fields.append('name')
|
update_fields.append('name')
|
||||||
# Reset revision if SCM source has changed parameters
|
|
||||||
if self.source == 'scm' and not is_new_instance:
|
|
||||||
before_is = self.__class__.objects.get(pk=self.pk)
|
|
||||||
if before_is.source_path != self.source_path or before_is.source_project_id != self.source_project_id:
|
|
||||||
# Reset the scm_revision if file changed to force update
|
|
||||||
self.scm_last_revision = ''
|
|
||||||
if 'scm_last_revision' not in update_fields:
|
|
||||||
update_fields.append('scm_last_revision')
|
|
||||||
|
|
||||||
# Do the actual save.
|
# Do the actual save.
|
||||||
super(InventorySource, self).save(*args, **kwargs)
|
super(InventorySource, self).save(*args, **kwargs)
|
||||||
@@ -1054,10 +1050,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
if replace_text in self.name:
|
if replace_text in self.name:
|
||||||
self.name = self.name.replace(replace_text, str(self.pk))
|
self.name = self.name.replace(replace_text, str(self.pk))
|
||||||
super(InventorySource, self).save(update_fields=['name'])
|
super(InventorySource, self).save(update_fields=['name'])
|
||||||
if self.source == 'scm' and is_new_instance and self.update_on_project_update:
|
|
||||||
# Schedule a new Project update if one is not already queued
|
|
||||||
if self.source_project and not self.source_project.project_updates.filter(status__in=['new', 'pending', 'waiting']).exists():
|
|
||||||
self.update()
|
|
||||||
if not getattr(_inventory_updates, 'is_updating', False):
|
if not getattr(_inventory_updates, 'is_updating', False):
|
||||||
if self.inventory is not None:
|
if self.inventory is not None:
|
||||||
self.inventory.update_computed_fields()
|
self.inventory.update_computed_fields()
|
||||||
@@ -1147,25 +1139,6 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
)
|
)
|
||||||
return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates))
|
return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates))
|
||||||
|
|
||||||
def clean_update_on_project_update(self):
|
|
||||||
if (
|
|
||||||
self.update_on_project_update is True
|
|
||||||
and self.source == 'scm'
|
|
||||||
and InventorySource.objects.filter(Q(inventory=self.inventory, update_on_project_update=True, source='scm') & ~Q(id=self.id)).exists()
|
|
||||||
):
|
|
||||||
raise ValidationError(_("More than one SCM-based inventory source with update on project update per-inventory not allowed."))
|
|
||||||
return self.update_on_project_update
|
|
||||||
|
|
||||||
def clean_update_on_launch(self):
|
|
||||||
if self.update_on_project_update is True and self.source == 'scm' and self.update_on_launch is True:
|
|
||||||
raise ValidationError(
|
|
||||||
_(
|
|
||||||
"Cannot update SCM-based inventory source on launch if set to update on project update. "
|
|
||||||
"Instead, configure the corresponding source project to update on launch."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return self.update_on_launch
|
|
||||||
|
|
||||||
def clean_source_path(self):
|
def clean_source_path(self):
|
||||||
if self.source != 'scm' and self.source_path:
|
if self.source != 'scm' and self.source_path:
|
||||||
raise ValidationError(_("Cannot set source_path if not SCM type."))
|
raise ValidationError(_("Cannot set source_path if not SCM type."))
|
||||||
@@ -1262,8 +1235,7 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
|
|||||||
return UnpartitionedInventoryUpdateEvent
|
return UnpartitionedInventoryUpdateEvent
|
||||||
return InventoryUpdateEvent
|
return InventoryUpdateEvent
|
||||||
|
|
||||||
@property
|
def _get_task_impact(self):
|
||||||
def task_impact(self):
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# InventoryUpdate credential required
|
# InventoryUpdate credential required
|
||||||
@@ -1301,13 +1273,6 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
|
|||||||
return self.global_instance_groups
|
return self.global_instance_groups
|
||||||
return selected_groups
|
return selected_groups
|
||||||
|
|
||||||
def cancel(self, job_explanation=None, is_chain=False):
|
|
||||||
res = super(InventoryUpdate, self).cancel(job_explanation=job_explanation, is_chain=is_chain)
|
|
||||||
if res:
|
|
||||||
if self.launch_type != 'scm' and self.source_project_update:
|
|
||||||
self.source_project_update.cancel(job_explanation=job_explanation)
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin):
|
class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -130,8 +130,7 @@ class JobOptions(BaseModel):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
job_tags = models.CharField(
|
job_tags = models.TextField(
|
||||||
max_length=1024,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
)
|
)
|
||||||
@@ -601,6 +600,19 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
def get_ui_url(self):
|
def get_ui_url(self):
|
||||||
return urljoin(settings.TOWER_URL_BASE, "/#/jobs/playbook/{}".format(self.pk))
|
return urljoin(settings.TOWER_URL_BASE, "/#/jobs/playbook/{}".format(self.pk))
|
||||||
|
|
||||||
|
def _set_default_dependencies_processed(self):
|
||||||
|
"""
|
||||||
|
This sets the initial value of dependencies_processed
|
||||||
|
and here we use this as a shortcut to avoid the DependencyManager for jobs that do not need it
|
||||||
|
"""
|
||||||
|
if (not self.project) or self.project.scm_update_on_launch:
|
||||||
|
self.dependencies_processed = False
|
||||||
|
elif (not self.inventory) or self.inventory.inventory_sources.filter(update_on_launch=True).exists():
|
||||||
|
self.dependencies_processed = False
|
||||||
|
else:
|
||||||
|
# No dependencies to process
|
||||||
|
self.dependencies_processed = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def event_class(self):
|
def event_class(self):
|
||||||
if self.has_unpartitioned_events:
|
if self.has_unpartitioned_events:
|
||||||
@@ -645,8 +657,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
raise ParseError(_('{status_value} is not a valid status option.').format(status_value=status))
|
raise ParseError(_('{status_value} is not a valid status option.').format(status_value=status))
|
||||||
return self._get_hosts(**kwargs)
|
return self._get_hosts(**kwargs)
|
||||||
|
|
||||||
@property
|
def _get_task_impact(self):
|
||||||
def task_impact(self):
|
|
||||||
if self.launch_type == 'callback':
|
if self.launch_type == 'callback':
|
||||||
count_hosts = 2
|
count_hosts = 2
|
||||||
else:
|
else:
|
||||||
@@ -744,6 +755,12 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
return "$hidden due to Ansible no_log flag$"
|
return "$hidden due to Ansible no_log flag$"
|
||||||
return artifacts
|
return artifacts
|
||||||
|
|
||||||
|
def get_effective_artifacts(self, **kwargs):
|
||||||
|
"""Return unified job artifacts (from set_stats) to pass downstream in workflows"""
|
||||||
|
if isinstance(self.artifacts, dict):
|
||||||
|
return self.artifacts
|
||||||
|
return {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_container_group_task(self):
|
def is_container_group_task(self):
|
||||||
return bool(self.instance_group and self.instance_group.is_container_group)
|
return bool(self.instance_group and self.instance_group.is_container_group)
|
||||||
@@ -797,7 +814,8 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
def _get_inventory_hosts(self, only=['name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id']):
|
def _get_inventory_hosts(self, only=['name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id']):
|
||||||
if not self.inventory:
|
if not self.inventory:
|
||||||
return []
|
return []
|
||||||
return self.inventory.hosts.only(*only)
|
host_queryset = self.inventory.hosts.only(*only)
|
||||||
|
return self.inventory.get_sliced_hosts(host_queryset, self.job_slice_number, self.job_slice_count)
|
||||||
|
|
||||||
def start_job_fact_cache(self, destination, modification_times, timeout=None):
|
def start_job_fact_cache(self, destination, modification_times, timeout=None):
|
||||||
self.log_lifecycle("start_job_fact_cache")
|
self.log_lifecycle("start_job_fact_cache")
|
||||||
@@ -842,7 +860,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
continue
|
continue
|
||||||
host.ansible_facts = ansible_facts
|
host.ansible_facts = ansible_facts
|
||||||
host.ansible_facts_modified = now()
|
host.ansible_facts_modified = now()
|
||||||
host.save()
|
host.save(update_fields=['ansible_facts', 'ansible_facts_modified'])
|
||||||
system_tracking_logger.info(
|
system_tracking_logger.info(
|
||||||
'New fact for inventory {} host {}'.format(smart_str(host.inventory.name), smart_str(host.name)),
|
'New fact for inventory {} host {}'.format(smart_str(host.inventory.name), smart_str(host.name)),
|
||||||
extra=dict(
|
extra=dict(
|
||||||
@@ -1208,6 +1226,9 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin):
|
|||||||
|
|
||||||
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
||||||
|
|
||||||
|
def _set_default_dependencies_processed(self):
|
||||||
|
self.dependencies_processed = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_parent_field_name(cls):
|
def _get_parent_field_name(cls):
|
||||||
return 'system_job_template'
|
return 'system_job_template'
|
||||||
@@ -1233,8 +1254,7 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin):
|
|||||||
return UnpartitionedSystemJobEvent
|
return UnpartitionedSystemJobEvent
|
||||||
return SystemJobEvent
|
return SystemJobEvent
|
||||||
|
|
||||||
@property
|
def _get_task_impact(self):
|
||||||
def task_impact(self):
|
|
||||||
return 5
|
return 5
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -407,41 +407,54 @@ class TaskManagerUnifiedJobMixin(models.Model):
|
|||||||
def get_jobs_fail_chain(self):
|
def get_jobs_fail_chain(self):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def dependent_jobs_finished(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class TaskManagerJobMixin(TaskManagerUnifiedJobMixin):
|
class TaskManagerJobMixin(TaskManagerUnifiedJobMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def get_jobs_fail_chain(self):
|
|
||||||
return [self.project_update] if self.project_update else []
|
|
||||||
|
|
||||||
def dependent_jobs_finished(self):
|
|
||||||
for j in self.dependent_jobs.all():
|
|
||||||
if j.status in ['pending', 'waiting', 'running']:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class TaskManagerUpdateOnLaunchMixin(TaskManagerUnifiedJobMixin):
|
class TaskManagerUpdateOnLaunchMixin(TaskManagerUnifiedJobMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def get_jobs_fail_chain(self):
|
|
||||||
return list(self.dependent_jobs.all())
|
|
||||||
|
|
||||||
|
|
||||||
class TaskManagerProjectUpdateMixin(TaskManagerUpdateOnLaunchMixin):
|
class TaskManagerProjectUpdateMixin(TaskManagerUpdateOnLaunchMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
def get_jobs_fail_chain(self):
|
||||||
|
# project update can be a dependency of an inventory update, in which
|
||||||
|
# case we need to fail the job that may have spawned the inventory
|
||||||
|
# update.
|
||||||
|
# The inventory update will fail, but since it is not running it will
|
||||||
|
# not cascade fail to the job from the errback logic in apply_async. As
|
||||||
|
# such we should capture it here.
|
||||||
|
blocked_jobs = list(self.unifiedjob_blocked_jobs.all().prefetch_related("unifiedjob_blocked_jobs"))
|
||||||
|
other_tasks = []
|
||||||
|
for b in blocked_jobs:
|
||||||
|
other_tasks += list(b.unifiedjob_blocked_jobs.all())
|
||||||
|
return blocked_jobs + other_tasks
|
||||||
|
|
||||||
|
|
||||||
class TaskManagerInventoryUpdateMixin(TaskManagerUpdateOnLaunchMixin):
|
class TaskManagerInventoryUpdateMixin(TaskManagerUpdateOnLaunchMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
def get_jobs_fail_chain(self):
|
||||||
|
blocked_jobs = list(self.unifiedjob_blocked_jobs.all())
|
||||||
|
other_updates = []
|
||||||
|
if blocked_jobs:
|
||||||
|
# blocked_jobs[0] is just a reference to a job that depends on this
|
||||||
|
# inventory update.
|
||||||
|
# We can look at the dependencies of this blocked job to find other
|
||||||
|
# inventory sources that are safe to fail.
|
||||||
|
# Since the dependencies could also include project updates,
|
||||||
|
# we need to check for type.
|
||||||
|
for dep in blocked_jobs[0].dependent_jobs.all():
|
||||||
|
if type(dep) is type(self) and dep.id != self.id:
|
||||||
|
other_updates.append(dep)
|
||||||
|
return blocked_jobs + other_updates
|
||||||
|
|
||||||
|
|
||||||
class ExecutionEnvironmentMixin(models.Model):
|
class ExecutionEnvironmentMixin(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ class JobNotificationMixin(object):
|
|||||||
'inventory': 'Stub Inventory',
|
'inventory': 'Stub Inventory',
|
||||||
'id': 42,
|
'id': 42,
|
||||||
'hosts': {},
|
'hosts': {},
|
||||||
|
'extra_vars': {},
|
||||||
'friendly_name': 'Job',
|
'friendly_name': 'Job',
|
||||||
'finished': False,
|
'finished': False,
|
||||||
'credential': 'Stub credential',
|
'credential': 'Stub credential',
|
||||||
@@ -421,21 +422,8 @@ class JobNotificationMixin(object):
|
|||||||
The context will contain allowed content retrieved from a serialized job object
|
The context will contain allowed content retrieved from a serialized job object
|
||||||
(see JobNotificationMixin.JOB_FIELDS_ALLOWED_LIST the job's friendly name,
|
(see JobNotificationMixin.JOB_FIELDS_ALLOWED_LIST the job's friendly name,
|
||||||
and a url to the job run."""
|
and a url to the job run."""
|
||||||
job_context = {'host_status_counts': {}}
|
|
||||||
summary = None
|
|
||||||
try:
|
|
||||||
has_event_property = any([f for f in self.event_class._meta.fields if f.name == 'event'])
|
|
||||||
except NotImplementedError:
|
|
||||||
has_event_property = False
|
|
||||||
if has_event_property:
|
|
||||||
qs = self.get_event_queryset()
|
|
||||||
if qs:
|
|
||||||
event = qs.only('event_data').filter(event='playbook_on_stats').first()
|
|
||||||
if event:
|
|
||||||
summary = event.get_host_status_counts()
|
|
||||||
job_context['host_status_counts'] = summary
|
|
||||||
context = {
|
context = {
|
||||||
'job': job_context,
|
'job': {'host_status_counts': self.host_status_counts},
|
||||||
'job_friendly_name': self.get_notification_friendly_name(),
|
'job_friendly_name': self.get_notification_friendly_name(),
|
||||||
'url': self.get_ui_url(),
|
'url': self.get_ui_url(),
|
||||||
'job_metadata': json.dumps(self.notification_data(), ensure_ascii=False, indent=4),
|
'job_metadata': json.dumps(self.notification_data(), ensure_ascii=False, indent=4),
|
||||||
|
|||||||
@@ -114,13 +114,6 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
|
|||||||
def _get_related_jobs(self):
|
def _get_related_jobs(self):
|
||||||
return UnifiedJob.objects.non_polymorphic().filter(organization=self)
|
return UnifiedJob.objects.non_polymorphic().filter(organization=self)
|
||||||
|
|
||||||
def create_default_galaxy_credential(self):
|
|
||||||
from awx.main.models import Credential
|
|
||||||
|
|
||||||
public_galaxy_credential = Credential.objects.filter(managed=True, name='Ansible Galaxy').first()
|
|
||||||
if public_galaxy_credential is not None and public_galaxy_credential not in self.galaxy_credentials.all():
|
|
||||||
self.galaxy_credentials.add(public_galaxy_credential)
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationGalaxyCredentialMembership(models.Model):
|
class OrganizationGalaxyCredentialMembership(models.Model):
|
||||||
|
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
# If update_fields has been specified, add our field names to it,
|
# If update_fields has been specified, add our field names to it,
|
||||||
# if it hasn't been specified, then we're just doing a normal save.
|
# if it hasn't been specified, then we're just doing a normal save.
|
||||||
update_fields = kwargs.get('update_fields', [])
|
update_fields = kwargs.get('update_fields', [])
|
||||||
skip_update = bool(kwargs.pop('skip_update', False))
|
self._skip_update = bool(kwargs.pop('skip_update', False))
|
||||||
# Create auto-generated local path if project uses SCM.
|
# Create auto-generated local path if project uses SCM.
|
||||||
if self.pk and self.scm_type and not self.local_path.startswith('_'):
|
if self.pk and self.scm_type and not self.local_path.startswith('_'):
|
||||||
slug_name = slugify(str(self.name)).replace(u'-', u'_')
|
slug_name = slugify(str(self.name)).replace(u'-', u'_')
|
||||||
@@ -372,14 +372,16 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
from awx.main.signals import disable_activity_stream
|
from awx.main.signals import disable_activity_stream
|
||||||
|
|
||||||
with disable_activity_stream():
|
with disable_activity_stream():
|
||||||
self.save(update_fields=update_fields)
|
self.save(update_fields=update_fields, skip_update=self._skip_update)
|
||||||
# If we just created a new project with SCM, start the initial update.
|
# If we just created a new project with SCM, start the initial update.
|
||||||
# also update if certain fields have changed
|
# also update if certain fields have changed
|
||||||
relevant_change = any(pre_save_vals.get(fd_name, None) != self._prior_values_store.get(fd_name, None) for fd_name in self.FIELDS_TRIGGER_UPDATE)
|
relevant_change = any(pre_save_vals.get(fd_name, None) != self._prior_values_store.get(fd_name, None) for fd_name in self.FIELDS_TRIGGER_UPDATE)
|
||||||
if (relevant_change or new_instance) and (not skip_update) and self.scm_type:
|
if (relevant_change or new_instance) and (not self._skip_update) and self.scm_type:
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _get_current_status(self):
|
def _get_current_status(self):
|
||||||
|
if getattr(self, '_skip_update', False):
|
||||||
|
return self.status
|
||||||
if self.scm_type:
|
if self.scm_type:
|
||||||
if self.current_job and self.current_job.status:
|
if self.current_job and self.current_job.status:
|
||||||
return self.current_job.status
|
return self.current_job.status
|
||||||
@@ -511,6 +513,9 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage
|
|||||||
help_text=_('The SCM Revision discovered by this update for the given project and branch.'),
|
help_text=_('The SCM Revision discovered by this update for the given project and branch.'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _set_default_dependencies_processed(self):
|
||||||
|
self.dependencies_processed = True
|
||||||
|
|
||||||
def _get_parent_field_name(self):
|
def _get_parent_field_name(self):
|
||||||
return 'project'
|
return 'project'
|
||||||
|
|
||||||
@@ -558,8 +563,7 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage
|
|||||||
return UnpartitionedProjectUpdateEvent
|
return UnpartitionedProjectUpdateEvent
|
||||||
return ProjectUpdateEvent
|
return ProjectUpdateEvent
|
||||||
|
|
||||||
@property
|
def _get_task_impact(self):
|
||||||
def task_impact(self):
|
|
||||||
return 0 if self.job_type == 'run' else 1
|
return 0 if self.job_type == 'run' else 1
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -81,32 +81,41 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
|
|||||||
dtend = models.DateTimeField(
|
dtend = models.DateTimeField(
|
||||||
null=True, default=None, editable=False, help_text=_("The last occurrence of the schedule occurs before this time, aftewards the schedule expires.")
|
null=True, default=None, editable=False, help_text=_("The last occurrence of the schedule occurs before this time, aftewards the schedule expires.")
|
||||||
)
|
)
|
||||||
rrule = models.CharField(max_length=255, help_text=_("A value representing the schedules iCal recurrence rule."))
|
rrule = models.TextField(help_text=_("A value representing the schedules iCal recurrence rule."))
|
||||||
next_run = models.DateTimeField(null=True, default=None, editable=False, help_text=_("The next time that the scheduled action will run."))
|
next_run = models.DateTimeField(null=True, default=None, editable=False, help_text=_("The next time that the scheduled action will run."))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_zoneinfo(self):
|
def get_zoneinfo(cls):
|
||||||
return sorted(get_zonefile_instance().zones)
|
return sorted(get_zonefile_instance().zones)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_zoneinfo_links(cls):
|
||||||
|
return_val = {}
|
||||||
|
zone_instance = get_zonefile_instance()
|
||||||
|
for zone_name in zone_instance.zones:
|
||||||
|
if str(zone_name) != str(zone_instance.zones[zone_name]._filename):
|
||||||
|
return_val[zone_name] = zone_instance.zones[zone_name]._filename
|
||||||
|
return return_val
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timezone(self):
|
def timezone(self):
|
||||||
utc = tzutc()
|
utc = tzutc()
|
||||||
|
# All rules in a ruleset will have the same dtstart so we can just take the first rule
|
||||||
|
tzinfo = Schedule.rrulestr(self.rrule)._rrule[0]._dtstart.tzinfo
|
||||||
|
if tzinfo is utc:
|
||||||
|
return 'UTC'
|
||||||
all_zones = Schedule.get_zoneinfo()
|
all_zones = Schedule.get_zoneinfo()
|
||||||
all_zones.sort(key=lambda x: -len(x))
|
all_zones.sort(key=lambda x: -len(x))
|
||||||
for r in Schedule.rrulestr(self.rrule)._rrule:
|
fname = getattr(tzinfo, '_filename', None)
|
||||||
if r._dtstart:
|
if fname:
|
||||||
tzinfo = r._dtstart.tzinfo
|
for zone in all_zones:
|
||||||
if tzinfo is utc:
|
if fname.endswith(zone):
|
||||||
return 'UTC'
|
return zone
|
||||||
fname = getattr(tzinfo, '_filename', None)
|
|
||||||
if fname:
|
|
||||||
for zone in all_zones:
|
|
||||||
if fname.endswith(zone):
|
|
||||||
return zone
|
|
||||||
logger.warning('Could not detect valid zoneinfo for {}'.format(self.rrule))
|
logger.warning('Could not detect valid zoneinfo for {}'.format(self.rrule))
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
# TODO: How would we handle multiple until parameters? The UI is currently using this on the edit screen of a schedule
|
||||||
def until(self):
|
def until(self):
|
||||||
# The UNTIL= datestamp (if any) coerced from UTC to the local naive time
|
# The UNTIL= datestamp (if any) coerced from UTC to the local naive time
|
||||||
# of the DTSTART
|
# of the DTSTART
|
||||||
@@ -134,34 +143,48 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
|
|||||||
# timezone (America/New_York), and so we'll coerce to UTC _for you_
|
# timezone (America/New_York), and so we'll coerce to UTC _for you_
|
||||||
# automatically.
|
# automatically.
|
||||||
#
|
#
|
||||||
if 'until=' in rrule.lower():
|
|
||||||
# if DTSTART;TZID= is used, coerce "naive" UNTIL values
|
|
||||||
# to the proper UTC date
|
|
||||||
match_until = re.match(r".*?(?P<until>UNTIL\=[0-9]+T[0-9]+)(?P<utcflag>Z?)", rrule)
|
|
||||||
if not len(match_until.group('utcflag')):
|
|
||||||
# rrule = DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000
|
|
||||||
|
|
||||||
# Find the UNTIL=N part of the string
|
# Find the DTSTART rule or raise an error, its usually the first rule but that is not strictly enforced
|
||||||
# naive_until = UNTIL=20200601T170000
|
start_date_rule = re.sub('^.*(DTSTART[^\s]+)\s.*$', r'\1', rrule)
|
||||||
naive_until = match_until.group('until')
|
if not start_date_rule:
|
||||||
|
raise ValueError('A DTSTART field needs to be in the rrule')
|
||||||
|
|
||||||
# What is the DTSTART timezone for:
|
rules = re.split(r'\s+', rrule)
|
||||||
# DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000Z
|
for index in range(0, len(rules)):
|
||||||
# local_tz = tzfile('/usr/share/zoneinfo/America/New_York')
|
rule = rules[index]
|
||||||
local_tz = dateutil.rrule.rrulestr(rrule.replace(naive_until, naive_until + 'Z'), tzinfos=UTC_TIMEZONES)._dtstart.tzinfo
|
if 'until=' in rule.lower():
|
||||||
|
# if DTSTART;TZID= is used, coerce "naive" UNTIL values
|
||||||
|
# to the proper UTC date
|
||||||
|
match_until = re.match(r".*?(?P<until>UNTIL\=[0-9]+T[0-9]+)(?P<utcflag>Z?)", rule)
|
||||||
|
if not len(match_until.group('utcflag')):
|
||||||
|
# rule = DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000
|
||||||
|
|
||||||
# Make a datetime object with tzinfo=<the DTSTART timezone>
|
# Find the UNTIL=N part of the string
|
||||||
# localized_until = datetime.datetime(2020, 6, 1, 17, 0, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))
|
# naive_until = UNTIL=20200601T170000
|
||||||
localized_until = make_aware(datetime.datetime.strptime(re.sub('^UNTIL=', '', naive_until), "%Y%m%dT%H%M%S"), local_tz)
|
naive_until = match_until.group('until')
|
||||||
|
|
||||||
# Coerce the datetime to UTC and format it as a string w/ Zulu format
|
# What is the DTSTART timezone for:
|
||||||
# utc_until = UNTIL=20200601T220000Z
|
# DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000Z
|
||||||
utc_until = 'UNTIL=' + localized_until.astimezone(pytz.utc).strftime('%Y%m%dT%H%M%SZ')
|
# local_tz = tzfile('/usr/share/zoneinfo/America/New_York')
|
||||||
|
# We are going to construct a 'dummy' rule for parsing which will include the DTSTART and the rest of the rule
|
||||||
|
temp_rule = "{} {}".format(start_date_rule, rule.replace(naive_until, naive_until + 'Z'))
|
||||||
|
# If the rule is an EX rule we have to add an RRULE to it because an EX rule alone will not manifest into a ruleset
|
||||||
|
if rule.lower().startswith('ex'):
|
||||||
|
temp_rule = "{} {}".format(temp_rule, 'RRULE:FREQ=MINUTELY;INTERVAL=1;UNTIL=20380601T170000Z')
|
||||||
|
local_tz = dateutil.rrule.rrulestr(temp_rule, tzinfos=UTC_TIMEZONES, **{'forceset': True})._rrule[0]._dtstart.tzinfo
|
||||||
|
|
||||||
# rrule was: DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000
|
# Make a datetime object with tzinfo=<the DTSTART timezone>
|
||||||
# rrule is now: DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T220000Z
|
# localized_until = datetime.datetime(2020, 6, 1, 17, 0, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))
|
||||||
rrule = rrule.replace(naive_until, utc_until)
|
localized_until = make_aware(datetime.datetime.strptime(re.sub('^UNTIL=', '', naive_until), "%Y%m%dT%H%M%S"), local_tz)
|
||||||
return rrule
|
|
||||||
|
# Coerce the datetime to UTC and format it as a string w/ Zulu format
|
||||||
|
# utc_until = UNTIL=20200601T220000Z
|
||||||
|
utc_until = 'UNTIL=' + localized_until.astimezone(pytz.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
|
||||||
|
# rule was: DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000
|
||||||
|
# rule is now: DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T220000Z
|
||||||
|
rules[index] = rule.replace(naive_until, utc_until)
|
||||||
|
return " ".join(rules)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def rrulestr(cls, rrule, fast_forward=True, **kwargs):
|
def rrulestr(cls, rrule, fast_forward=True, **kwargs):
|
||||||
@@ -176,20 +199,28 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
|
|||||||
if r._dtstart and r._dtstart.tzinfo is None:
|
if r._dtstart and r._dtstart.tzinfo is None:
|
||||||
raise ValueError('A valid TZID must be provided (e.g., America/New_York)')
|
raise ValueError('A valid TZID must be provided (e.g., America/New_York)')
|
||||||
|
|
||||||
if fast_forward and ('MINUTELY' in rrule or 'HOURLY' in rrule) and 'COUNT=' not in rrule:
|
# Fast forward is a way for us to limit the number of events in the rruleset
|
||||||
|
# If we are fastforwading and we don't have a count limited rule that is minutely or hourley
|
||||||
|
# We will modify the start date of the rule to last week to prevent a large number of entries
|
||||||
|
if fast_forward:
|
||||||
try:
|
try:
|
||||||
|
# All rules in a ruleset will have the same dtstart value
|
||||||
|
# so lets compare the first event to now to see if its > 7 days old
|
||||||
first_event = x[0]
|
first_event = x[0]
|
||||||
# If the first event was over a week ago...
|
|
||||||
if (now() - first_event).days > 7:
|
if (now() - first_event).days > 7:
|
||||||
# hourly/minutely rrules with far-past DTSTART values
|
for rule in x._rrule:
|
||||||
# are *really* slow to precompute
|
# If any rule has a minutely or hourly rule without a count...
|
||||||
# start *from* one week ago to speed things up drastically
|
if rule._freq in [dateutil.rrule.MINUTELY, dateutil.rrule.HOURLY] and not rule._count:
|
||||||
dtstart = x._rrule[0]._dtstart.strftime(':%Y%m%dT')
|
# hourly/minutely rrules with far-past DTSTART values
|
||||||
new_start = (now() - datetime.timedelta(days=7)).strftime(':%Y%m%dT')
|
# are *really* slow to precompute
|
||||||
new_rrule = rrule.replace(dtstart, new_start)
|
# start *from* one week ago to speed things up drastically
|
||||||
return Schedule.rrulestr(new_rrule, fast_forward=False)
|
new_start = (now() - datetime.timedelta(days=7)).strftime('%Y%m%d')
|
||||||
|
# Now we want to repalce the DTSTART:<value>T with the new date (which includes the T)
|
||||||
|
new_rrule = re.sub('(DTSTART[^:]*):[^T]+T', r'\1:{0}T'.format(new_start), rrule)
|
||||||
|
return Schedule.rrulestr(new_rrule, fast_forward=False)
|
||||||
except IndexError:
|
except IndexError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return x
|
return x
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -206,6 +237,22 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
|
|||||||
job_kwargs['_eager_fields'] = {'launch_type': 'scheduled', 'schedule': self}
|
job_kwargs['_eager_fields'] = {'launch_type': 'scheduled', 'schedule': self}
|
||||||
return job_kwargs
|
return job_kwargs
|
||||||
|
|
||||||
|
def get_end_date(ruleset):
|
||||||
|
# if we have a complex ruleset with a lot of options getting the last index of the ruleset can take some time
|
||||||
|
# And a ruleset without a count/until can come back as datetime.datetime(9999, 12, 31, 15, 0, tzinfo=tzfile('US/Eastern'))
|
||||||
|
# So we are going to do a quick scan to make sure we would have an end date
|
||||||
|
for a_rule in ruleset._rrule:
|
||||||
|
# if this rule does not have until or count in it then we have no end date
|
||||||
|
if not a_rule._until and not a_rule._count:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# If we made it this far we should have an end date and can ask the ruleset what the last date is
|
||||||
|
# However, if the until/count is before dtstart we will get an IndexError when trying to get [-1]
|
||||||
|
try:
|
||||||
|
return ruleset[-1].astimezone(pytz.utc)
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
|
||||||
def update_computed_fields_no_save(self):
|
def update_computed_fields_no_save(self):
|
||||||
affects_fields = ['next_run', 'dtstart', 'dtend']
|
affects_fields = ['next_run', 'dtstart', 'dtend']
|
||||||
starting_values = {}
|
starting_values = {}
|
||||||
@@ -229,12 +276,7 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
|
|||||||
self.dtstart = future_rs[0].astimezone(pytz.utc)
|
self.dtstart = future_rs[0].astimezone(pytz.utc)
|
||||||
except IndexError:
|
except IndexError:
|
||||||
self.dtstart = None
|
self.dtstart = None
|
||||||
self.dtend = None
|
self.dtend = Schedule.get_end_date(future_rs)
|
||||||
if 'until' in self.rrule.lower() or 'count' in self.rrule.lower():
|
|
||||||
try:
|
|
||||||
self.dtend = future_rs[-1].astimezone(pytz.utc)
|
|
||||||
except IndexError:
|
|
||||||
self.dtend = None
|
|
||||||
|
|
||||||
changed = any(getattr(self, field_name) != starting_values[field_name] for field_name in affects_fields)
|
changed = any(getattr(self, field_name) != starting_values[field_name] for field_name in affects_fields)
|
||||||
return changed
|
return changed
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ from awx.main.utils.common import (
|
|||||||
get_type_for_model,
|
get_type_for_model,
|
||||||
parse_yaml_or_json,
|
parse_yaml_or_json,
|
||||||
getattr_dne,
|
getattr_dne,
|
||||||
schedule_task_manager,
|
ScheduleDependencyManager,
|
||||||
|
ScheduleTaskManager,
|
||||||
get_event_partition_epoch,
|
get_event_partition_epoch,
|
||||||
get_capacity_type,
|
get_capacity_type,
|
||||||
)
|
)
|
||||||
@@ -381,6 +382,11 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, ExecutionEn
|
|||||||
unified_job.survey_passwords = new_job_passwords
|
unified_job.survey_passwords = new_job_passwords
|
||||||
kwargs['survey_passwords'] = new_job_passwords # saved in config object for relaunch
|
kwargs['survey_passwords'] = new_job_passwords # saved in config object for relaunch
|
||||||
|
|
||||||
|
unified_job.preferred_instance_groups_cache = unified_job._get_preferred_instance_group_cache()
|
||||||
|
|
||||||
|
unified_job._set_default_dependencies_processed()
|
||||||
|
unified_job.task_impact = unified_job._get_task_impact()
|
||||||
|
|
||||||
from awx.main.signals import disable_activity_stream, activity_stream_create
|
from awx.main.signals import disable_activity_stream, activity_stream_create
|
||||||
|
|
||||||
with disable_activity_stream():
|
with disable_activity_stream():
|
||||||
@@ -533,7 +539,7 @@ class UnifiedJob(
|
|||||||
('workflow', _('Workflow')), # Job was started from a workflow job.
|
('workflow', _('Workflow')), # Job was started from a workflow job.
|
||||||
('webhook', _('Webhook')), # Job was started from a webhook event.
|
('webhook', _('Webhook')), # Job was started from a webhook event.
|
||||||
('sync', _('Sync')), # Job was started from a project sync.
|
('sync', _('Sync')), # Job was started from a project sync.
|
||||||
('scm', _('SCM Update')), # Job was created as an Inventory SCM sync.
|
('scm', _('SCM Update')), # (deprecated) Job was created as an Inventory SCM sync.
|
||||||
]
|
]
|
||||||
|
|
||||||
PASSWORD_FIELDS = ('start_args',)
|
PASSWORD_FIELDS = ('start_args',)
|
||||||
@@ -575,7 +581,8 @@ class UnifiedJob(
|
|||||||
dependent_jobs = models.ManyToManyField(
|
dependent_jobs = models.ManyToManyField(
|
||||||
'self',
|
'self',
|
||||||
editable=False,
|
editable=False,
|
||||||
related_name='%(class)s_blocked_jobs+',
|
related_name='%(class)s_blocked_jobs',
|
||||||
|
symmetrical=False,
|
||||||
)
|
)
|
||||||
execution_node = models.TextField(
|
execution_node = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -692,6 +699,14 @@ class UnifiedJob(
|
|||||||
on_delete=polymorphic.SET_NULL,
|
on_delete=polymorphic.SET_NULL,
|
||||||
help_text=_('The Instance group the job was run under'),
|
help_text=_('The Instance group the job was run under'),
|
||||||
)
|
)
|
||||||
|
preferred_instance_groups_cache = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
editable=False,
|
||||||
|
help_text=_("A cached list with pk values from preferred instance groups."),
|
||||||
|
)
|
||||||
|
task_impact = models.PositiveIntegerField(default=0, editable=False, help_text=_("Number of forks an instance consumes when running this job."))
|
||||||
organization = models.ForeignKey(
|
organization = models.ForeignKey(
|
||||||
'Organization',
|
'Organization',
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -717,6 +732,13 @@ class UnifiedJob(
|
|||||||
editable=False,
|
editable=False,
|
||||||
help_text=_("The version of Ansible Core installed in the execution environment."),
|
help_text=_("The version of Ansible Core installed in the execution environment."),
|
||||||
)
|
)
|
||||||
|
host_status_counts = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
editable=False,
|
||||||
|
help_text=_("Playbook stats from the Ansible playbook_on_stats event."),
|
||||||
|
)
|
||||||
work_unit_id = models.CharField(
|
work_unit_id = models.CharField(
|
||||||
max_length=255, blank=True, default=None, editable=False, null=True, help_text=_("The Receptor work unit ID associated with this job.")
|
max_length=255, blank=True, default=None, editable=False, null=True, help_text=_("The Receptor work unit ID associated with this job.")
|
||||||
)
|
)
|
||||||
@@ -746,6 +768,9 @@ class UnifiedJob(
|
|||||||
def _get_parent_field_name(self):
|
def _get_parent_field_name(self):
|
||||||
return 'unified_job_template' # Override in subclasses.
|
return 'unified_job_template' # Override in subclasses.
|
||||||
|
|
||||||
|
def _get_preferred_instance_group_cache(self):
|
||||||
|
return [ig.pk for ig in self.preferred_instance_groups]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_unified_job_template_class(cls):
|
def _get_unified_job_template_class(cls):
|
||||||
"""
|
"""
|
||||||
@@ -800,6 +825,9 @@ class UnifiedJob(
|
|||||||
update_fields = self._update_parent_instance_no_save(parent_instance)
|
update_fields = self._update_parent_instance_no_save(parent_instance)
|
||||||
parent_instance.save(update_fields=update_fields)
|
parent_instance.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
def _set_default_dependencies_processed(self):
|
||||||
|
pass
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Save the job, with current status, to the database.
|
"""Save the job, with current status, to the database.
|
||||||
Ensure that all data is consistent before doing so.
|
Ensure that all data is consistent before doing so.
|
||||||
@@ -813,7 +841,8 @@ class UnifiedJob(
|
|||||||
|
|
||||||
# If this job already exists in the database, retrieve a copy of
|
# If this job already exists in the database, retrieve a copy of
|
||||||
# the job in its prior state.
|
# the job in its prior state.
|
||||||
if self.pk:
|
# If update_fields are given without status, then that indicates no change
|
||||||
|
if self.pk and ((not update_fields) or ('status' in update_fields)):
|
||||||
self_before = self.__class__.objects.get(pk=self.pk)
|
self_before = self.__class__.objects.get(pk=self.pk)
|
||||||
if self_before.status != self.status:
|
if self_before.status != self.status:
|
||||||
status_before = self_before.status
|
status_before = self_before.status
|
||||||
@@ -1018,7 +1047,6 @@ class UnifiedJob(
|
|||||||
event_qs = self.get_event_queryset()
|
event_qs = self.get_event_queryset()
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return True # Model without events, such as WFJT
|
return True # Model without events, such as WFJT
|
||||||
self.log_lifecycle("event_processing_finished")
|
|
||||||
return self.emitted_events == event_qs.count()
|
return self.emitted_events == event_qs.count()
|
||||||
|
|
||||||
def result_stdout_raw_handle(self, enforce_max_bytes=True):
|
def result_stdout_raw_handle(self, enforce_max_bytes=True):
|
||||||
@@ -1196,6 +1224,10 @@ class UnifiedJob(
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_effective_artifacts(self, **kwargs):
|
||||||
|
"""Return unified job artifacts (from set_stats) to pass downstream in workflows"""
|
||||||
|
return {}
|
||||||
|
|
||||||
def get_passwords_needed_to_start(self):
|
def get_passwords_needed_to_start(self):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -1229,9 +1261,8 @@ class UnifiedJob(
|
|||||||
except JobLaunchConfig.DoesNotExist:
|
except JobLaunchConfig.DoesNotExist:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
def _get_task_impact(self):
|
||||||
def task_impact(self):
|
return self.task_impact # return default, should implement in subclass.
|
||||||
raise NotImplementedError # Implement in subclass.
|
|
||||||
|
|
||||||
def websocket_emit_data(self):
|
def websocket_emit_data(self):
|
||||||
'''Return extra data that should be included when submitting data to the browser over the websocket connection'''
|
'''Return extra data that should be included when submitting data to the browser over the websocket connection'''
|
||||||
@@ -1243,7 +1274,7 @@ class UnifiedJob(
|
|||||||
def _websocket_emit_status(self, status):
|
def _websocket_emit_status(self, status):
|
||||||
try:
|
try:
|
||||||
status_data = dict(unified_job_id=self.id, status=status)
|
status_data = dict(unified_job_id=self.id, status=status)
|
||||||
if status == 'waiting':
|
if status == 'running':
|
||||||
if self.instance_group:
|
if self.instance_group:
|
||||||
status_data['instance_group_name'] = self.instance_group.name
|
status_data['instance_group_name'] = self.instance_group.name
|
||||||
else:
|
else:
|
||||||
@@ -1346,7 +1377,10 @@ class UnifiedJob(
|
|||||||
self.update_fields(start_args=json.dumps(kwargs), status='pending')
|
self.update_fields(start_args=json.dumps(kwargs), status='pending')
|
||||||
self.websocket_emit_status("pending")
|
self.websocket_emit_status("pending")
|
||||||
|
|
||||||
schedule_task_manager()
|
if self.dependencies_processed:
|
||||||
|
ScheduleTaskManager().schedule()
|
||||||
|
else:
|
||||||
|
ScheduleDependencyManager().schedule()
|
||||||
|
|
||||||
# Each type of unified job has a different Task class; get the
|
# Each type of unified job has a different Task class; get the
|
||||||
# appropirate one.
|
# appropirate one.
|
||||||
@@ -1372,9 +1406,10 @@ class UnifiedJob(
|
|||||||
timeout = 5
|
timeout = 5
|
||||||
try:
|
try:
|
||||||
running = self.celery_task_id in ControlDispatcher('dispatcher', self.controller_node or self.execution_node).running(timeout=timeout)
|
running = self.celery_task_id in ControlDispatcher('dispatcher', self.controller_node or self.execution_node).running(timeout=timeout)
|
||||||
except (socket.timeout, RuntimeError):
|
except socket.timeout:
|
||||||
logger.error('could not reach dispatcher on {} within {}s'.format(self.execution_node, timeout))
|
logger.error('could not reach dispatcher on {} within {}s'.format(self.execution_node, timeout))
|
||||||
running = False
|
except Exception:
|
||||||
|
logger.exception("error encountered when checking task status")
|
||||||
return running
|
return running
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -1503,8 +1538,8 @@ class UnifiedJob(
|
|||||||
'state': state,
|
'state': state,
|
||||||
'work_unit_id': self.work_unit_id,
|
'work_unit_id': self.work_unit_id,
|
||||||
}
|
}
|
||||||
if self.unified_job_template:
|
if self.name:
|
||||||
extra["template_name"] = self.unified_job_template.name
|
extra["task_name"] = self.name
|
||||||
if state == "blocked" and blocked_by:
|
if state == "blocked" and blocked_by:
|
||||||
blocked_by_msg = f"{blocked_by._meta.model_name}-{blocked_by.id}"
|
blocked_by_msg = f"{blocked_by._meta.model_name}-{blocked_by.id}"
|
||||||
msg = f"{self._meta.model_name}-{self.id} blocked by {blocked_by_msg}"
|
msg = f"{self._meta.model_name}-{self.id} blocked by {blocked_by_msg}"
|
||||||
@@ -1516,7 +1551,7 @@ class UnifiedJob(
|
|||||||
extra["controller_node"] = self.controller_node or "NOT_SET"
|
extra["controller_node"] = self.controller_node or "NOT_SET"
|
||||||
elif state == "execution_node_chosen":
|
elif state == "execution_node_chosen":
|
||||||
extra["execution_node"] = self.execution_node or "NOT_SET"
|
extra["execution_node"] = self.execution_node or "NOT_SET"
|
||||||
logger_job_lifecycle.debug(msg, extra=extra)
|
logger_job_lifecycle.info(msg, extra=extra)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def launched_by(self):
|
def launched_by(self):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from django.db import connection, models
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.utils.timezone import now, timedelta
|
||||||
|
|
||||||
# from django import settings as tower_settings
|
# from django import settings as tower_settings
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ from awx.main.models.mixins import (
|
|||||||
from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemplate
|
from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemplate
|
||||||
from awx.main.models.credential import Credential
|
from awx.main.models.credential import Credential
|
||||||
from awx.main.redact import REPLACE_STR
|
from awx.main.redact import REPLACE_STR
|
||||||
from awx.main.utils import schedule_task_manager
|
from awx.main.utils import ScheduleWorkflowManager
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -318,8 +319,8 @@ class WorkflowJobNode(WorkflowNodeBase):
|
|||||||
for parent_node in self.get_parent_nodes():
|
for parent_node in self.get_parent_nodes():
|
||||||
is_root_node = False
|
is_root_node = False
|
||||||
aa_dict.update(parent_node.ancestor_artifacts)
|
aa_dict.update(parent_node.ancestor_artifacts)
|
||||||
if parent_node.job and hasattr(parent_node.job, 'artifacts'):
|
if parent_node.job:
|
||||||
aa_dict.update(parent_node.job.artifacts)
|
aa_dict.update(parent_node.job.get_effective_artifacts(parents_set=set([self.workflow_job_id])))
|
||||||
if aa_dict and not is_root_node:
|
if aa_dict and not is_root_node:
|
||||||
self.ancestor_artifacts = aa_dict
|
self.ancestor_artifacts = aa_dict
|
||||||
self.save(update_fields=['ancestor_artifacts'])
|
self.save(update_fields=['ancestor_artifacts'])
|
||||||
@@ -622,6 +623,9 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
|
|||||||
)
|
)
|
||||||
is_sliced_job = models.BooleanField(default=False)
|
is_sliced_job = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def _set_default_dependencies_processed(self):
|
||||||
|
self.dependencies_processed = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def workflow_nodes(self):
|
def workflow_nodes(self):
|
||||||
return self.workflow_job_nodes
|
return self.workflow_job_nodes
|
||||||
@@ -659,10 +663,16 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
|
|||||||
node_job_description = 'job #{0}, "{1}", which finished with status {2}.'.format(node.job.id, node.job.name, node.job.status)
|
node_job_description = 'job #{0}, "{1}", which finished with status {2}.'.format(node.job.id, node.job.name, node.job.status)
|
||||||
str_arr.append("- node #{0} spawns {1}".format(node.id, node_job_description))
|
str_arr.append("- node #{0} spawns {1}".format(node.id, node_job_description))
|
||||||
result['body'] = '\n'.join(str_arr)
|
result['body'] = '\n'.join(str_arr)
|
||||||
|
result.update(
|
||||||
|
dict(
|
||||||
|
inventory=self.inventory.name if self.inventory else None,
|
||||||
|
limit=self.limit,
|
||||||
|
extra_vars=self.display_extra_vars(),
|
||||||
|
)
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@property
|
def _get_task_impact(self):
|
||||||
def task_impact(self):
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def get_ancestor_workflows(self):
|
def get_ancestor_workflows(self):
|
||||||
@@ -682,6 +692,27 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
|
|||||||
wj = wj.get_workflow_job()
|
wj = wj.get_workflow_job()
|
||||||
return ancestors
|
return ancestors
|
||||||
|
|
||||||
|
def get_effective_artifacts(self, **kwargs):
|
||||||
|
"""
|
||||||
|
For downstream jobs of a workflow nested inside of a workflow,
|
||||||
|
we send aggregated artifacts from the nodes inside of the nested workflow
|
||||||
|
"""
|
||||||
|
artifacts = {}
|
||||||
|
job_queryset = (
|
||||||
|
UnifiedJob.objects.filter(unified_job_node__workflow_job=self)
|
||||||
|
.defer('job_args', 'job_cwd', 'start_args', 'result_traceback')
|
||||||
|
.order_by('finished', 'id')
|
||||||
|
.filter(status__in=['successful', 'failed'])
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
parents_set = kwargs.get('parents_set', set())
|
||||||
|
new_parents_set = parents_set | {self.id}
|
||||||
|
for job in job_queryset:
|
||||||
|
if job.id in parents_set:
|
||||||
|
continue
|
||||||
|
artifacts.update(job.get_effective_artifacts(parents_set=new_parents_set))
|
||||||
|
return artifacts
|
||||||
|
|
||||||
def get_notification_templates(self):
|
def get_notification_templates(self):
|
||||||
return self.workflow_job_template.notification_templates
|
return self.workflow_job_template.notification_templates
|
||||||
|
|
||||||
@@ -755,6 +786,12 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
default=0,
|
default=0,
|
||||||
help_text=_("The amount of time (in seconds) before the approval node expires and fails."),
|
help_text=_("The amount of time (in seconds) before the approval node expires and fails."),
|
||||||
)
|
)
|
||||||
|
expires = models.DateTimeField(
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
editable=False,
|
||||||
|
help_text=_("The time this approval will expire. This is the created time plus timeout, used for filtering."),
|
||||||
|
)
|
||||||
timed_out = models.BooleanField(default=False, help_text=_("Shows when an approval node (with a timeout assigned to it) has timed out."))
|
timed_out = models.BooleanField(default=False, help_text=_("Shows when an approval node (with a timeout assigned to it) has timed out."))
|
||||||
approved_or_denied_by = models.ForeignKey(
|
approved_or_denied_by = models.ForeignKey(
|
||||||
'auth.User',
|
'auth.User',
|
||||||
@@ -765,6 +802,9 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _set_default_dependencies_processed(self):
|
||||||
|
self.dependencies_processed = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_unified_job_template_class(cls):
|
def _get_unified_job_template_class(cls):
|
||||||
return WorkflowApprovalTemplate
|
return WorkflowApprovalTemplate
|
||||||
@@ -782,13 +822,32 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
def _get_parent_field_name(self):
|
def _get_parent_field_name(self):
|
||||||
return 'workflow_approval_template'
|
return 'workflow_approval_template'
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
update_fields = list(kwargs.get('update_fields', []))
|
||||||
|
if self.timeout != 0 and ((not self.pk) or (not update_fields) or ('timeout' in update_fields)):
|
||||||
|
if not self.created: # on creation, created will be set by parent class, so we fudge it here
|
||||||
|
created = now()
|
||||||
|
else:
|
||||||
|
created = self.created
|
||||||
|
new_expires = created + timedelta(seconds=self.timeout)
|
||||||
|
if new_expires != self.expires:
|
||||||
|
self.expires = new_expires
|
||||||
|
if update_fields and 'expires' not in update_fields:
|
||||||
|
update_fields.append('expires')
|
||||||
|
elif self.timeout == 0 and ((not update_fields) or ('timeout' in update_fields)):
|
||||||
|
if self.expires:
|
||||||
|
self.expires = None
|
||||||
|
if update_fields and 'expires' not in update_fields:
|
||||||
|
update_fields.append('expires')
|
||||||
|
super(WorkflowApproval, self).save(*args, **kwargs)
|
||||||
|
|
||||||
def approve(self, request=None):
|
def approve(self, request=None):
|
||||||
self.status = 'successful'
|
self.status = 'successful'
|
||||||
self.approved_or_denied_by = get_current_user()
|
self.approved_or_denied_by = get_current_user()
|
||||||
self.save()
|
self.save()
|
||||||
self.send_approval_notification('approved')
|
self.send_approval_notification('approved')
|
||||||
self.websocket_emit_status(self.status)
|
self.websocket_emit_status(self.status)
|
||||||
schedule_task_manager()
|
ScheduleWorkflowManager().schedule()
|
||||||
return reverse('api:workflow_approval_approve', kwargs={'pk': self.pk}, request=request)
|
return reverse('api:workflow_approval_approve', kwargs={'pk': self.pk}, request=request)
|
||||||
|
|
||||||
def deny(self, request=None):
|
def deny(self, request=None):
|
||||||
@@ -797,7 +856,7 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
self.save()
|
self.save()
|
||||||
self.send_approval_notification('denied')
|
self.send_approval_notification('denied')
|
||||||
self.websocket_emit_status(self.status)
|
self.websocket_emit_status(self.status)
|
||||||
schedule_task_manager()
|
ScheduleWorkflowManager().schedule()
|
||||||
return reverse('api:workflow_approval_deny', kwargs={'pk': self.pk}, request=request)
|
return reverse('api:workflow_approval_deny', kwargs={'pk': self.pk}, request=request)
|
||||||
|
|
||||||
def signal_start(self, **kwargs):
|
def signal_start(self, **kwargs):
|
||||||
@@ -885,3 +944,12 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
|||||||
@property
|
@property
|
||||||
def workflow_job(self):
|
def workflow_job(self):
|
||||||
return self.unified_job_node.workflow_job
|
return self.unified_job_node.workflow_job
|
||||||
|
|
||||||
|
def notification_data(self):
|
||||||
|
result = super(WorkflowApproval, self).notification_data()
|
||||||
|
result.update(
|
||||||
|
dict(
|
||||||
|
extra_vars=self.workflow_job.display_extra_vars(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import redis
|
|||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import awx.main.analytics.subsystem_metrics as s_metrics
|
|
||||||
|
|
||||||
__all__ = ['CallbackQueueDispatcher']
|
__all__ = ['CallbackQueueDispatcher']
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ class CallbackQueueDispatcher(object):
|
|||||||
self.queue = getattr(settings, 'CALLBACK_QUEUE', '')
|
self.queue = getattr(settings, 'CALLBACK_QUEUE', '')
|
||||||
self.logger = logging.getLogger('awx.main.queue.CallbackQueueDispatcher')
|
self.logger = logging.getLogger('awx.main.queue.CallbackQueueDispatcher')
|
||||||
self.connection = redis.Redis.from_url(settings.BROKER_URL)
|
self.connection = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
self.subsystem_metrics = s_metrics.Metrics()
|
|
||||||
|
|
||||||
def dispatch(self, obj):
|
def dispatch(self, obj):
|
||||||
self.connection.rpush(self.queue, json.dumps(obj, cls=AnsibleJSONEncoder))
|
self.connection.rpush(self.queue, json.dumps(obj, cls=AnsibleJSONEncoder))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Copyright (c) 2017 Ansible, Inc.
|
# Copyright (c) 2017 Ansible, Inc.
|
||||||
#
|
#
|
||||||
|
|
||||||
from .task_manager import TaskManager
|
from .task_manager import TaskManager, DependencyManager, WorkflowManager
|
||||||
|
|
||||||
__all__ = ['TaskManager']
|
__all__ = ['TaskManager', 'DependencyManager', 'WorkflowManager']
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from awx.main.models import (
|
|||||||
WorkflowJob,
|
WorkflowJob,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('awx.main.scheduler.dependency_graph')
|
||||||
|
|
||||||
|
|
||||||
class DependencyGraph(object):
|
class DependencyGraph(object):
|
||||||
PROJECT_UPDATES = 'project_updates'
|
PROJECT_UPDATES = 'project_updates'
|
||||||
@@ -26,7 +31,7 @@ class DependencyGraph(object):
|
|||||||
# The reason for tracking both inventory and inventory sources:
|
# The reason for tracking both inventory and inventory sources:
|
||||||
# Consider InvA, which has two sources, InvSource1, InvSource2.
|
# Consider InvA, which has two sources, InvSource1, InvSource2.
|
||||||
# JobB might depend on InvA, which launches two updates, one for each source.
|
# JobB might depend on InvA, which launches two updates, one for each source.
|
||||||
# To determine if JobB can run, we can just check InvA, which is marked in
|
# To determine if JobB can run, we can just check InvA, which is marked in
|
||||||
# INVENTORY_UPDATES, instead of having to check for both entries in
|
# INVENTORY_UPDATES, instead of having to check for both entries in
|
||||||
# INVENTORY_SOURCE_UPDATES.
|
# INVENTORY_SOURCE_UPDATES.
|
||||||
self.data[self.INVENTORY_UPDATES] = {}
|
self.data[self.INVENTORY_UPDATES] = {}
|
||||||
@@ -36,6 +41,9 @@ class DependencyGraph(object):
|
|||||||
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS] = {}
|
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS] = {}
|
||||||
|
|
||||||
def mark_if_no_key(self, job_type, id, job):
|
def mark_if_no_key(self, job_type, id, job):
|
||||||
|
if id is None:
|
||||||
|
logger.warning(f'Null dependency graph key from {job}, could be integrity error or bug, ignoring')
|
||||||
|
return
|
||||||
# only mark first occurrence of a task. If 10 of JobA are launched
|
# only mark first occurrence of a task. If 10 of JobA are launched
|
||||||
# (concurrent disabled), the dependency graph should return that jobs
|
# (concurrent disabled), the dependency graph should return that jobs
|
||||||
# 2 through 10 are blocked by job1
|
# 2 through 10 are blocked by job1
|
||||||
@@ -66,7 +74,10 @@ class DependencyGraph(object):
|
|||||||
self.mark_if_no_key(self.JOB_TEMPLATE_JOBS, job.job_template_id, job)
|
self.mark_if_no_key(self.JOB_TEMPLATE_JOBS, job.job_template_id, job)
|
||||||
|
|
||||||
def mark_workflow_job(self, job):
|
def mark_workflow_job(self, job):
|
||||||
self.mark_if_no_key(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id, job)
|
if job.workflow_job_template_id:
|
||||||
|
self.mark_if_no_key(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id, job)
|
||||||
|
elif job.unified_job_template_id: # for sliced jobs
|
||||||
|
self.mark_if_no_key(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.unified_job_template_id, job)
|
||||||
|
|
||||||
def project_update_blocked_by(self, job):
|
def project_update_blocked_by(self, job):
|
||||||
return self.get_item(self.PROJECT_UPDATES, job.project_id)
|
return self.get_item(self.PROJECT_UPDATES, job.project_id)
|
||||||
@@ -85,7 +96,13 @@ class DependencyGraph(object):
|
|||||||
|
|
||||||
def workflow_job_blocked_by(self, job):
|
def workflow_job_blocked_by(self, job):
|
||||||
if job.allow_simultaneous is False:
|
if job.allow_simultaneous is False:
|
||||||
return self.get_item(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id)
|
if job.workflow_job_template_id:
|
||||||
|
return self.get_item(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id)
|
||||||
|
elif job.unified_job_template_id:
|
||||||
|
# Sliced jobs can be either Job or WorkflowJob type, and either should block a sliced WorkflowJob
|
||||||
|
return self.get_item(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.unified_job_template_id) or self.get_item(
|
||||||
|
self.JOB_TEMPLATE_JOBS, job.unified_job_template_id
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def system_job_blocked_by(self, job):
|
def system_job_blocked_by(self, job):
|
||||||
|
|||||||
@@ -6,176 +6,152 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import signal
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.db import transaction, connection
|
from django.db import transaction
|
||||||
from django.utils.translation import gettext_lazy as _, gettext_noop
|
from django.utils.translation import gettext_lazy as _, gettext_noop
|
||||||
from django.utils.timezone import now as tz_now
|
from django.utils.timezone import now as tz_now
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.dispatch.reaper import reap_job
|
from awx.main.dispatch.reaper import reap_job
|
||||||
from awx.main.models import (
|
from awx.main.models import (
|
||||||
AdHocCommand,
|
|
||||||
Instance,
|
Instance,
|
||||||
InventorySource,
|
InventorySource,
|
||||||
InventoryUpdate,
|
InventoryUpdate,
|
||||||
Job,
|
Job,
|
||||||
Project,
|
Project,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
SystemJob,
|
|
||||||
UnifiedJob,
|
UnifiedJob,
|
||||||
WorkflowApproval,
|
WorkflowApproval,
|
||||||
WorkflowJob,
|
WorkflowJob,
|
||||||
|
WorkflowJobNode,
|
||||||
WorkflowJobTemplate,
|
WorkflowJobTemplate,
|
||||||
)
|
)
|
||||||
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
||||||
from awx.main.utils.pglock import advisory_lock
|
from awx.main.utils.pglock import advisory_lock
|
||||||
from awx.main.utils import get_type_for_model, task_manager_bulk_reschedule, schedule_task_manager
|
from awx.main.utils import (
|
||||||
from awx.main.utils.common import create_partition
|
get_type_for_model,
|
||||||
|
ScheduleTaskManager,
|
||||||
|
ScheduleWorkflowManager,
|
||||||
|
)
|
||||||
|
from awx.main.utils.common import task_manager_bulk_reschedule
|
||||||
from awx.main.signals import disable_activity_stream
|
from awx.main.signals import disable_activity_stream
|
||||||
|
from awx.main.constants import ACTIVE_STATES
|
||||||
from awx.main.scheduler.dependency_graph import DependencyGraph
|
from awx.main.scheduler.dependency_graph import DependencyGraph
|
||||||
from awx.main.scheduler.task_manager_models import TaskManagerInstances
|
from awx.main.scheduler.task_manager_models import TaskManagerInstances
|
||||||
from awx.main.scheduler.task_manager_models import TaskManagerInstanceGroups
|
from awx.main.scheduler.task_manager_models import TaskManagerInstanceGroups
|
||||||
|
import awx.main.analytics.subsystem_metrics as s_metrics
|
||||||
from awx.main.utils import decrypt_field
|
from awx.main.utils import decrypt_field
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.scheduler')
|
logger = logging.getLogger('awx.main.scheduler')
|
||||||
|
|
||||||
|
|
||||||
class TaskManager:
|
def timeit(func):
|
||||||
def __init__(self):
|
def inner(*args, **kwargs):
|
||||||
"""
|
t_now = time.perf_counter()
|
||||||
Do NOT put database queries or other potentially expensive operations
|
result = func(*args, **kwargs)
|
||||||
in the task manager init. The task manager object is created every time a
|
dur = time.perf_counter() - t_now
|
||||||
job is created, transitions state, and every 30 seconds on each tower node.
|
args[0].subsystem_metrics.inc(f"{args[0].prefix}_{func.__name__}_seconds", dur)
|
||||||
More often then not, the object is destroyed quickly because the NOOP case is hit.
|
return result
|
||||||
|
|
||||||
The NOOP case is short-circuit logic. If the task manager realizes that another instance
|
return inner
|
||||||
of the task manager is already running, then it short-circuits and decides not to run.
|
|
||||||
"""
|
|
||||||
# start task limit indicates how many pending jobs can be started on this
|
class TaskBase:
|
||||||
# .schedule() run. Starting jobs is expensive, and there is code in place to reap
|
def __init__(self, prefix=""):
|
||||||
# the task manager after 5 minutes. At scale, the task manager can easily take more than
|
self.prefix = prefix
|
||||||
# 5 minutes to start pending jobs. If this limit is reached, pending jobs
|
# initialize each metric to 0 and force metric_has_changed to true. This
|
||||||
# will no longer be started and will be started on the next task manager cycle.
|
# ensures each task manager metric will be overridden when pipe_execute
|
||||||
|
# is called later.
|
||||||
|
self.subsystem_metrics = s_metrics.Metrics(auto_pipe_execute=False)
|
||||||
|
self.start_time = time.time()
|
||||||
self.start_task_limit = settings.START_TASK_LIMIT
|
self.start_task_limit = settings.START_TASK_LIMIT
|
||||||
self.time_delta_job_explanation = timedelta(seconds=30)
|
for m in self.subsystem_metrics.METRICS:
|
||||||
|
if m.startswith(self.prefix):
|
||||||
|
self.subsystem_metrics.set(m, 0)
|
||||||
|
|
||||||
def after_lock_init(self, all_sorted_tasks):
|
def timed_out(self):
|
||||||
"""
|
"""Return True/False if we have met or exceeded the timeout for the task manager."""
|
||||||
Init AFTER we know this instance of the task manager will run because the lock is acquired.
|
elapsed = time.time() - self.start_time
|
||||||
"""
|
if elapsed >= settings.TASK_MANAGER_TIMEOUT:
|
||||||
self.dependency_graph = DependencyGraph()
|
logger.warning(f"{self.prefix} manager has run for {elapsed} which is greater than TASK_MANAGER_TIMEOUT of {settings.TASK_MANAGER_TIMEOUT}.")
|
||||||
self.instances = TaskManagerInstances(all_sorted_tasks)
|
return True
|
||||||
self.instance_groups = TaskManagerInstanceGroups(instances_by_hostname=self.instances)
|
return False
|
||||||
self.controlplane_ig = self.instance_groups.controlplane_ig
|
|
||||||
|
|
||||||
def job_blocked_by(self, task):
|
@timeit
|
||||||
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
|
def get_tasks(self, filter_args):
|
||||||
# in the old task manager this was handled as a method on each task object outside of the graph and
|
wf_approval_ctype_id = ContentType.objects.get_for_model(WorkflowApproval).id
|
||||||
# probably has the side effect of cutting down *a lot* of the logic from this task manager class
|
qs = (
|
||||||
blocked_by = self.dependency_graph.task_blocked_by(task)
|
UnifiedJob.objects.filter(**filter_args)
|
||||||
if blocked_by:
|
.exclude(launch_type='sync')
|
||||||
return blocked_by
|
.exclude(polymorphic_ctype_id=wf_approval_ctype_id)
|
||||||
|
.order_by('created')
|
||||||
if not task.dependent_jobs_finished():
|
.prefetch_related('dependent_jobs')
|
||||||
blocked_by = task.dependent_jobs.first()
|
|
||||||
if blocked_by:
|
|
||||||
return blocked_by
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_tasks(self, status_list=('pending', 'waiting', 'running')):
|
|
||||||
jobs = [j for j in Job.objects.filter(status__in=status_list).prefetch_related('instance_group')]
|
|
||||||
inventory_updates_qs = (
|
|
||||||
InventoryUpdate.objects.filter(status__in=status_list).exclude(source='file').prefetch_related('inventory_source', 'instance_group')
|
|
||||||
)
|
)
|
||||||
inventory_updates = [i for i in inventory_updates_qs]
|
self.all_tasks = [t for t in qs]
|
||||||
# Notice the job_type='check': we want to prevent implicit project updates from blocking our jobs.
|
|
||||||
project_updates = [p for p in ProjectUpdate.objects.filter(status__in=status_list, job_type='check').prefetch_related('instance_group')]
|
|
||||||
system_jobs = [s for s in SystemJob.objects.filter(status__in=status_list).prefetch_related('instance_group')]
|
|
||||||
ad_hoc_commands = [a for a in AdHocCommand.objects.filter(status__in=status_list).prefetch_related('instance_group')]
|
|
||||||
workflow_jobs = [w for w in WorkflowJob.objects.filter(status__in=status_list)]
|
|
||||||
all_tasks = sorted(jobs + project_updates + inventory_updates + system_jobs + ad_hoc_commands + workflow_jobs, key=lambda task: task.created)
|
|
||||||
return all_tasks
|
|
||||||
|
|
||||||
def get_running_workflow_jobs(self):
|
def record_aggregate_metrics(self, *args):
|
||||||
graph_workflow_jobs = [wf for wf in WorkflowJob.objects.filter(status='running')]
|
if not settings.IS_TESTING():
|
||||||
return graph_workflow_jobs
|
# increment task_manager_schedule_calls regardless if the other
|
||||||
|
# metrics are recorded
|
||||||
def get_inventory_source_tasks(self, all_sorted_tasks):
|
s_metrics.Metrics(auto_pipe_execute=True).inc(f"{self.prefix}__schedule_calls", 1)
|
||||||
inventory_ids = set()
|
# Only record metrics if the last time recording was more
|
||||||
for task in all_sorted_tasks:
|
# than SUBSYSTEM_METRICS_TASK_MANAGER_RECORD_INTERVAL ago.
|
||||||
if isinstance(task, Job):
|
# Prevents a short-duration task manager that runs directly after a
|
||||||
inventory_ids.add(task.inventory_id)
|
# long task manager to override useful metrics.
|
||||||
return [invsrc for invsrc in InventorySource.objects.filter(inventory_id__in=inventory_ids, update_on_launch=True)]
|
current_time = time.time()
|
||||||
|
time_last_recorded = current_time - self.subsystem_metrics.decode(f"{self.prefix}_recorded_timestamp")
|
||||||
def spawn_workflow_graph_jobs(self, workflow_jobs):
|
if time_last_recorded > settings.SUBSYSTEM_METRICS_TASK_MANAGER_RECORD_INTERVAL:
|
||||||
for workflow_job in workflow_jobs:
|
logger.debug(f"recording {self.prefix} metrics, last recorded {time_last_recorded} seconds ago")
|
||||||
if workflow_job.cancel_flag:
|
self.subsystem_metrics.set(f"{self.prefix}_recorded_timestamp", current_time)
|
||||||
logger.debug('Not spawning jobs for %s because it is pending cancelation.', workflow_job.log_format)
|
self.subsystem_metrics.pipe_execute()
|
||||||
continue
|
|
||||||
dag = WorkflowDAG(workflow_job)
|
|
||||||
spawn_nodes = dag.bfs_nodes_to_run()
|
|
||||||
if spawn_nodes:
|
|
||||||
logger.debug('Spawning jobs for %s', workflow_job.log_format)
|
|
||||||
else:
|
else:
|
||||||
logger.debug('No nodes to spawn for %s', workflow_job.log_format)
|
logger.debug(f"skipping recording {self.prefix} metrics, last recorded {time_last_recorded} seconds ago")
|
||||||
for spawn_node in spawn_nodes:
|
|
||||||
if spawn_node.unified_job_template is None:
|
|
||||||
continue
|
|
||||||
kv = spawn_node.get_job_kwargs()
|
|
||||||
job = spawn_node.unified_job_template.create_unified_job(**kv)
|
|
||||||
spawn_node.job = job
|
|
||||||
spawn_node.save()
|
|
||||||
logger.debug('Spawned %s in %s for node %s', job.log_format, workflow_job.log_format, spawn_node.pk)
|
|
||||||
can_start = True
|
|
||||||
if isinstance(spawn_node.unified_job_template, WorkflowJobTemplate):
|
|
||||||
workflow_ancestors = job.get_ancestor_workflows()
|
|
||||||
if spawn_node.unified_job_template in set(workflow_ancestors):
|
|
||||||
can_start = False
|
|
||||||
logger.info(
|
|
||||||
'Refusing to start recursive workflow-in-workflow id={}, wfjt={}, ancestors={}'.format(
|
|
||||||
job.id, spawn_node.unified_job_template.pk, [wa.pk for wa in workflow_ancestors]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
display_list = [spawn_node.unified_job_template] + workflow_ancestors
|
|
||||||
job.job_explanation = gettext_noop(
|
|
||||||
"Workflow Job spawned from workflow could not start because it " "would result in recursion (spawn order, most recent first: {})"
|
|
||||||
).format(', '.join(['<{}>'.format(tmp) for tmp in display_list]))
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
'Starting workflow-in-workflow id={}, wfjt={}, ancestors={}'.format(
|
|
||||||
job.id, spawn_node.unified_job_template.pk, [wa.pk for wa in workflow_ancestors]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not job._resources_sufficient_for_launch():
|
|
||||||
can_start = False
|
|
||||||
job.job_explanation = gettext_noop(
|
|
||||||
"Job spawned from workflow could not start because it " "was missing a related resource such as project or inventory"
|
|
||||||
)
|
|
||||||
if can_start:
|
|
||||||
if workflow_job.start_args:
|
|
||||||
start_args = json.loads(decrypt_field(workflow_job, 'start_args'))
|
|
||||||
else:
|
|
||||||
start_args = {}
|
|
||||||
can_start = job.signal_start(**start_args)
|
|
||||||
if not can_start:
|
|
||||||
job.job_explanation = gettext_noop(
|
|
||||||
"Job spawned from workflow could not start because it " "was not in the right state or required manual credentials"
|
|
||||||
)
|
|
||||||
if not can_start:
|
|
||||||
job.status = 'failed'
|
|
||||||
job.save(update_fields=['status', 'job_explanation'])
|
|
||||||
job.websocket_emit_status('failed')
|
|
||||||
|
|
||||||
# TODO: should we emit a status on the socket here similar to tasks.py awx_periodic_scheduler() ?
|
def record_aggregate_metrics_and_exit(self, *args):
|
||||||
# emit_websocket_notification('/socket.io/jobs', '', dict(id=))
|
self.record_aggregate_metrics()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
def process_finished_workflow_jobs(self, workflow_jobs):
|
def schedule(self):
|
||||||
|
# Lock
|
||||||
|
with task_manager_bulk_reschedule():
|
||||||
|
with advisory_lock(f"{self.prefix}_lock", wait=False) as acquired:
|
||||||
|
with transaction.atomic():
|
||||||
|
if acquired is False:
|
||||||
|
logger.debug(f"Not running {self.prefix} scheduler, another task holds lock")
|
||||||
|
return
|
||||||
|
logger.debug(f"Starting {self.prefix} Scheduler")
|
||||||
|
# if sigterm due to timeout, still record metrics
|
||||||
|
signal.signal(signal.SIGTERM, self.record_aggregate_metrics_and_exit)
|
||||||
|
self._schedule()
|
||||||
|
commit_start = time.time()
|
||||||
|
|
||||||
|
if self.prefix == "task_manager":
|
||||||
|
self.subsystem_metrics.set(f"{self.prefix}_commit_seconds", time.time() - commit_start)
|
||||||
|
self.record_aggregate_metrics()
|
||||||
|
logger.debug(f"Finishing {self.prefix} Scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowManager(TaskBase):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="workflow_manager")
|
||||||
|
|
||||||
|
@timeit
|
||||||
|
def spawn_workflow_graph_jobs(self):
|
||||||
result = []
|
result = []
|
||||||
for workflow_job in workflow_jobs:
|
for workflow_job in self.all_tasks:
|
||||||
|
if self.timed_out():
|
||||||
|
logger.warning("Workflow manager has reached time out while processing running workflows, exiting loop early")
|
||||||
|
ScheduleWorkflowManager().schedule()
|
||||||
|
# Do not process any more workflow jobs. Stop here.
|
||||||
|
# Maybe we should schedule another WorkflowManager run
|
||||||
|
break
|
||||||
dag = WorkflowDAG(workflow_job)
|
dag = WorkflowDAG(workflow_job)
|
||||||
status_changed = False
|
status_changed = False
|
||||||
if workflow_job.cancel_flag:
|
if workflow_job.cancel_flag:
|
||||||
@@ -190,99 +166,111 @@ class TaskManager:
|
|||||||
status_changed = True
|
status_changed = True
|
||||||
else:
|
else:
|
||||||
workflow_nodes = dag.mark_dnr_nodes()
|
workflow_nodes = dag.mark_dnr_nodes()
|
||||||
for n in workflow_nodes:
|
WorkflowJobNode.objects.bulk_update(workflow_nodes, ['do_not_run'])
|
||||||
n.save(update_fields=['do_not_run'])
|
# If workflow is now done, we do special things to mark it as done.
|
||||||
is_done = dag.is_workflow_done()
|
is_done = dag.is_workflow_done()
|
||||||
if not is_done:
|
if is_done:
|
||||||
continue
|
has_failed, reason = dag.has_workflow_failed()
|
||||||
has_failed, reason = dag.has_workflow_failed()
|
logger.debug('Marking %s as %s.', workflow_job.log_format, 'failed' if has_failed else 'successful')
|
||||||
logger.debug('Marking %s as %s.', workflow_job.log_format, 'failed' if has_failed else 'successful')
|
result.append(workflow_job.id)
|
||||||
result.append(workflow_job.id)
|
new_status = 'failed' if has_failed else 'successful'
|
||||||
new_status = 'failed' if has_failed else 'successful'
|
logger.debug("Transitioning {} to {} status.".format(workflow_job.log_format, new_status))
|
||||||
logger.debug("Transitioning {} to {} status.".format(workflow_job.log_format, new_status))
|
update_fields = ['status', 'start_args']
|
||||||
update_fields = ['status', 'start_args']
|
workflow_job.status = new_status
|
||||||
workflow_job.status = new_status
|
if reason:
|
||||||
if reason:
|
logger.info(f'Workflow job {workflow_job.id} failed due to reason: {reason}')
|
||||||
logger.info(f'Workflow job {workflow_job.id} failed due to reason: {reason}')
|
workflow_job.job_explanation = gettext_noop("No error handling paths found, marking workflow as failed")
|
||||||
workflow_job.job_explanation = gettext_noop("No error handling paths found, marking workflow as failed")
|
update_fields.append('job_explanation')
|
||||||
update_fields.append('job_explanation')
|
workflow_job.start_args = '' # blank field to remove encrypted passwords
|
||||||
workflow_job.start_args = '' # blank field to remove encrypted passwords
|
workflow_job.save(update_fields=update_fields)
|
||||||
workflow_job.save(update_fields=update_fields)
|
status_changed = True
|
||||||
status_changed = True
|
|
||||||
if status_changed:
|
if status_changed:
|
||||||
|
if workflow_job.spawned_by_workflow:
|
||||||
|
ScheduleWorkflowManager().schedule()
|
||||||
workflow_job.websocket_emit_status(workflow_job.status)
|
workflow_job.websocket_emit_status(workflow_job.status)
|
||||||
# Operations whose queries rely on modifications made during the atomic scheduling session
|
# Operations whose queries rely on modifications made during the atomic scheduling session
|
||||||
workflow_job.send_notification_templates('succeeded' if workflow_job.status == 'successful' else 'failed')
|
workflow_job.send_notification_templates('succeeded' if workflow_job.status == 'successful' else 'failed')
|
||||||
if workflow_job.spawned_by_workflow:
|
|
||||||
schedule_task_manager()
|
if workflow_job.status == 'running':
|
||||||
|
spawn_nodes = dag.bfs_nodes_to_run()
|
||||||
|
if spawn_nodes:
|
||||||
|
logger.debug('Spawning jobs for %s', workflow_job.log_format)
|
||||||
|
else:
|
||||||
|
logger.debug('No nodes to spawn for %s', workflow_job.log_format)
|
||||||
|
for spawn_node in spawn_nodes:
|
||||||
|
if spawn_node.unified_job_template is None:
|
||||||
|
continue
|
||||||
|
kv = spawn_node.get_job_kwargs()
|
||||||
|
job = spawn_node.unified_job_template.create_unified_job(**kv)
|
||||||
|
spawn_node.job = job
|
||||||
|
spawn_node.save()
|
||||||
|
logger.debug('Spawned %s in %s for node %s', job.log_format, workflow_job.log_format, spawn_node.pk)
|
||||||
|
can_start = True
|
||||||
|
if isinstance(spawn_node.unified_job_template, WorkflowJobTemplate):
|
||||||
|
workflow_ancestors = job.get_ancestor_workflows()
|
||||||
|
if spawn_node.unified_job_template in set(workflow_ancestors):
|
||||||
|
can_start = False
|
||||||
|
logger.info(
|
||||||
|
'Refusing to start recursive workflow-in-workflow id={}, wfjt={}, ancestors={}'.format(
|
||||||
|
job.id, spawn_node.unified_job_template.pk, [wa.pk for wa in workflow_ancestors]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
display_list = [spawn_node.unified_job_template] + workflow_ancestors
|
||||||
|
job.job_explanation = gettext_noop(
|
||||||
|
"Workflow Job spawned from workflow could not start because it "
|
||||||
|
"would result in recursion (spawn order, most recent first: {})"
|
||||||
|
).format(', '.join('<{}>'.format(tmp) for tmp in display_list))
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
'Starting workflow-in-workflow id={}, wfjt={}, ancestors={}'.format(
|
||||||
|
job.id, spawn_node.unified_job_template.pk, [wa.pk for wa in workflow_ancestors]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not job._resources_sufficient_for_launch():
|
||||||
|
can_start = False
|
||||||
|
job.job_explanation = gettext_noop(
|
||||||
|
"Job spawned from workflow could not start because it was missing a related resource such as project or inventory"
|
||||||
|
)
|
||||||
|
if can_start:
|
||||||
|
if workflow_job.start_args:
|
||||||
|
start_args = json.loads(decrypt_field(workflow_job, 'start_args'))
|
||||||
|
else:
|
||||||
|
start_args = {}
|
||||||
|
can_start = job.signal_start(**start_args)
|
||||||
|
if not can_start:
|
||||||
|
job.job_explanation = gettext_noop(
|
||||||
|
"Job spawned from workflow could not start because it was not in the right state or required manual credentials"
|
||||||
|
)
|
||||||
|
if not can_start:
|
||||||
|
job.status = 'failed'
|
||||||
|
job.save(update_fields=['status', 'job_explanation'])
|
||||||
|
job.websocket_emit_status('failed')
|
||||||
|
|
||||||
|
# TODO: should we emit a status on the socket here similar to tasks.py awx_periodic_scheduler() ?
|
||||||
|
# emit_websocket_notification('/socket.io/jobs', '', dict(id=))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def start_task(self, task, instance_group, dependent_tasks=None, instance=None):
|
@timeit
|
||||||
self.start_task_limit -= 1
|
def get_tasks(self, filter_args):
|
||||||
if self.start_task_limit == 0:
|
self.all_tasks = [wf for wf in WorkflowJob.objects.filter(**filter_args)]
|
||||||
# schedule another run immediately after this task manager
|
|
||||||
schedule_task_manager()
|
|
||||||
from awx.main.tasks.system import handle_work_error, handle_work_success
|
|
||||||
|
|
||||||
dependent_tasks = dependent_tasks or []
|
@timeit
|
||||||
|
def _schedule(self):
|
||||||
|
self.get_tasks(dict(status__in=["running"], dependencies_processed=True))
|
||||||
|
if len(self.all_tasks) > 0:
|
||||||
|
self.spawn_workflow_graph_jobs()
|
||||||
|
|
||||||
task_actual = {
|
|
||||||
'type': get_type_for_model(type(task)),
|
|
||||||
'id': task.id,
|
|
||||||
}
|
|
||||||
dependencies = [{'type': get_type_for_model(type(t)), 'id': t.id} for t in dependent_tasks]
|
|
||||||
|
|
||||||
task.status = 'waiting'
|
class DependencyManager(TaskBase):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(prefix="dependency_manager")
|
||||||
|
|
||||||
(start_status, opts) = task.pre_start()
|
def create_project_update(self, task, project_id=None):
|
||||||
if not start_status:
|
if project_id is None:
|
||||||
task.status = 'failed'
|
project_id = task.project_id
|
||||||
if task.job_explanation:
|
project_task = Project.objects.get(id=project_id).create_project_update(_eager_fields=dict(launch_type='dependency'))
|
||||||
task.job_explanation += ' '
|
|
||||||
task.job_explanation += 'Task failed pre-start check.'
|
|
||||||
task.save()
|
|
||||||
# TODO: run error handler to fail sub-tasks and send notifications
|
|
||||||
else:
|
|
||||||
if type(task) is WorkflowJob:
|
|
||||||
task.status = 'running'
|
|
||||||
task.send_notification_templates('running')
|
|
||||||
logger.debug('Transitioning %s to running status.', task.log_format)
|
|
||||||
schedule_task_manager()
|
|
||||||
# at this point we already have control/execution nodes selected for the following cases
|
|
||||||
else:
|
|
||||||
task.instance_group = instance_group
|
|
||||||
execution_node_msg = f' and execution node {task.execution_node}' if task.execution_node else ''
|
|
||||||
logger.debug(
|
|
||||||
f'Submitting job {task.log_format} controlled by {task.controller_node} to instance group {instance_group.name}{execution_node_msg}.'
|
|
||||||
)
|
|
||||||
with disable_activity_stream():
|
|
||||||
task.celery_task_id = str(uuid.uuid4())
|
|
||||||
task.save()
|
|
||||||
task.log_lifecycle("waiting")
|
|
||||||
|
|
||||||
def post_commit():
|
|
||||||
if task.status != 'failed' and type(task) is not WorkflowJob:
|
|
||||||
# Before task is dispatched, ensure that job_event partitions exist
|
|
||||||
create_partition(task.event_class._meta.db_table, start=task.created)
|
|
||||||
task_cls = task._get_task_class()
|
|
||||||
task_cls.apply_async(
|
|
||||||
[task.pk],
|
|
||||||
opts,
|
|
||||||
queue=task.get_queue_name(),
|
|
||||||
uuid=task.celery_task_id,
|
|
||||||
callbacks=[{'task': handle_work_success.name, 'kwargs': {'task_actual': task_actual}}],
|
|
||||||
errbacks=[{'task': handle_work_error.name, 'args': [task.celery_task_id], 'kwargs': {'subtasks': [task_actual] + dependencies}}],
|
|
||||||
)
|
|
||||||
|
|
||||||
task.websocket_emit_status(task.status) # adds to on_commit
|
|
||||||
connection.on_commit(post_commit)
|
|
||||||
|
|
||||||
def process_running_tasks(self, running_tasks):
|
|
||||||
for task in running_tasks:
|
|
||||||
self.dependency_graph.add_job(task)
|
|
||||||
|
|
||||||
def create_project_update(self, task):
|
|
||||||
project_task = Project.objects.get(id=task.project_id).create_project_update(_eager_fields=dict(launch_type='dependency'))
|
|
||||||
|
|
||||||
# Project created 1 seconds behind
|
# Project created 1 seconds behind
|
||||||
project_task.created = task.created - timedelta(seconds=1)
|
project_task.created = task.created - timedelta(seconds=1)
|
||||||
@@ -298,17 +286,19 @@ class TaskManager:
|
|||||||
inventory_task.status = 'pending'
|
inventory_task.status = 'pending'
|
||||||
inventory_task.save()
|
inventory_task.save()
|
||||||
logger.debug('Spawned {} as dependency of {}'.format(inventory_task.log_format, task.log_format))
|
logger.debug('Spawned {} as dependency of {}'.format(inventory_task.log_format, task.log_format))
|
||||||
# inventory_sources = self.get_inventory_source_tasks([task])
|
|
||||||
# self.process_inventory_sources(inventory_sources)
|
|
||||||
return inventory_task
|
return inventory_task
|
||||||
|
|
||||||
def capture_chain_failure_dependencies(self, task, dependencies):
|
def add_dependencies(self, task, dependencies):
|
||||||
with disable_activity_stream():
|
with disable_activity_stream():
|
||||||
task.dependent_jobs.add(*dependencies)
|
task.dependent_jobs.add(*dependencies)
|
||||||
|
|
||||||
for dep in dependencies:
|
def get_inventory_source_tasks(self):
|
||||||
# Add task + all deps except self
|
inventory_ids = set()
|
||||||
dep.dependent_jobs.add(*([task] + [d for d in dependencies if d != dep]))
|
for task in self.all_tasks:
|
||||||
|
if isinstance(task, Job):
|
||||||
|
inventory_ids.add(task.inventory_id)
|
||||||
|
self.all_inventory_sources = [invsrc for invsrc in InventorySource.objects.filter(inventory_id__in=inventory_ids, update_on_launch=True)]
|
||||||
|
|
||||||
def get_latest_inventory_update(self, inventory_source):
|
def get_latest_inventory_update(self, inventory_source):
|
||||||
latest_inventory_update = InventoryUpdate.objects.filter(inventory_source=inventory_source).order_by("-created")
|
latest_inventory_update = InventoryUpdate.objects.filter(inventory_source=inventory_source).order_by("-created")
|
||||||
@@ -335,8 +325,8 @@ class TaskManager:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_latest_project_update(self, job):
|
def get_latest_project_update(self, project_id):
|
||||||
latest_project_update = ProjectUpdate.objects.filter(project=job.project, job_type='check').order_by("-created")
|
latest_project_update = ProjectUpdate.objects.filter(project=project_id, job_type='check').order_by("-created")
|
||||||
if not latest_project_update.exists():
|
if not latest_project_update.exists():
|
||||||
return None
|
return None
|
||||||
return latest_project_update.first()
|
return latest_project_update.first()
|
||||||
@@ -376,55 +366,233 @@ class TaskManager:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def gen_dep_for_job(self, task):
|
||||||
|
created_dependencies = []
|
||||||
|
dependencies = []
|
||||||
|
# TODO: Can remove task.project None check after scan-job-default-playbook is removed
|
||||||
|
if task.project is not None and task.project.scm_update_on_launch is True:
|
||||||
|
latest_project_update = self.get_latest_project_update(task.project_id)
|
||||||
|
if self.should_update_related_project(task, latest_project_update):
|
||||||
|
latest_project_update = self.create_project_update(task)
|
||||||
|
created_dependencies.append(latest_project_update)
|
||||||
|
dependencies.append(latest_project_update)
|
||||||
|
|
||||||
|
# Inventory created 2 seconds behind job
|
||||||
|
try:
|
||||||
|
start_args = json.loads(decrypt_field(task, field_name="start_args"))
|
||||||
|
except ValueError:
|
||||||
|
start_args = dict()
|
||||||
|
# generator for inventory sources related to this task
|
||||||
|
task_inv_sources = (invsrc for invsrc in self.all_inventory_sources if invsrc.inventory_id == task.inventory_id)
|
||||||
|
for inventory_source in task_inv_sources:
|
||||||
|
if "inventory_sources_already_updated" in start_args and inventory_source.id in start_args['inventory_sources_already_updated']:
|
||||||
|
continue
|
||||||
|
if not inventory_source.update_on_launch:
|
||||||
|
continue
|
||||||
|
latest_inventory_update = self.get_latest_inventory_update(inventory_source)
|
||||||
|
if self.should_update_inventory_source(task, latest_inventory_update):
|
||||||
|
inventory_task = self.create_inventory_update(task, inventory_source)
|
||||||
|
created_dependencies.append(inventory_task)
|
||||||
|
dependencies.append(inventory_task)
|
||||||
|
else:
|
||||||
|
dependencies.append(latest_inventory_update)
|
||||||
|
|
||||||
|
if dependencies:
|
||||||
|
self.add_dependencies(task, dependencies)
|
||||||
|
|
||||||
|
return created_dependencies
|
||||||
|
|
||||||
|
def gen_dep_for_inventory_update(self, inventory_task):
|
||||||
|
created_dependencies = []
|
||||||
|
if inventory_task.source == "scm":
|
||||||
|
invsrc = inventory_task.inventory_source
|
||||||
|
if not invsrc.source_project.scm_update_on_launch:
|
||||||
|
return created_dependencies
|
||||||
|
|
||||||
|
latest_src_project_update = self.get_latest_project_update(invsrc.source_project_id)
|
||||||
|
if self.should_update_related_project(inventory_task, latest_src_project_update):
|
||||||
|
latest_src_project_update = self.create_project_update(inventory_task, project_id=invsrc.source_project_id)
|
||||||
|
created_dependencies.append(latest_src_project_update)
|
||||||
|
self.add_dependencies(inventory_task, [latest_src_project_update])
|
||||||
|
latest_src_project_update.scm_inventory_updates.add(inventory_task)
|
||||||
|
return created_dependencies
|
||||||
|
|
||||||
|
@timeit
|
||||||
def generate_dependencies(self, undeped_tasks):
|
def generate_dependencies(self, undeped_tasks):
|
||||||
created_dependencies = []
|
created_dependencies = []
|
||||||
for task in undeped_tasks:
|
for task in undeped_tasks:
|
||||||
task.log_lifecycle("acknowledged")
|
task.log_lifecycle("acknowledged")
|
||||||
dependencies = []
|
if type(task) is Job:
|
||||||
if not type(task) is Job:
|
created_dependencies += self.gen_dep_for_job(task)
|
||||||
|
elif type(task) is InventoryUpdate:
|
||||||
|
created_dependencies += self.gen_dep_for_inventory_update(task)
|
||||||
|
else:
|
||||||
continue
|
continue
|
||||||
# TODO: Can remove task.project None check after scan-job-default-playbook is removed
|
|
||||||
if task.project is not None and task.project.scm_update_on_launch is True:
|
|
||||||
latest_project_update = self.get_latest_project_update(task)
|
|
||||||
if self.should_update_related_project(task, latest_project_update):
|
|
||||||
project_task = self.create_project_update(task)
|
|
||||||
created_dependencies.append(project_task)
|
|
||||||
dependencies.append(project_task)
|
|
||||||
else:
|
|
||||||
dependencies.append(latest_project_update)
|
|
||||||
|
|
||||||
# Inventory created 2 seconds behind job
|
|
||||||
try:
|
|
||||||
start_args = json.loads(decrypt_field(task, field_name="start_args"))
|
|
||||||
except ValueError:
|
|
||||||
start_args = dict()
|
|
||||||
for inventory_source in [invsrc for invsrc in self.all_inventory_sources if invsrc.inventory == task.inventory]:
|
|
||||||
if "inventory_sources_already_updated" in start_args and inventory_source.id in start_args['inventory_sources_already_updated']:
|
|
||||||
continue
|
|
||||||
if not inventory_source.update_on_launch:
|
|
||||||
continue
|
|
||||||
latest_inventory_update = self.get_latest_inventory_update(inventory_source)
|
|
||||||
if self.should_update_inventory_source(task, latest_inventory_update):
|
|
||||||
inventory_task = self.create_inventory_update(task, inventory_source)
|
|
||||||
created_dependencies.append(inventory_task)
|
|
||||||
dependencies.append(inventory_task)
|
|
||||||
else:
|
|
||||||
dependencies.append(latest_inventory_update)
|
|
||||||
|
|
||||||
if len(dependencies) > 0:
|
|
||||||
self.capture_chain_failure_dependencies(task, dependencies)
|
|
||||||
|
|
||||||
UnifiedJob.objects.filter(pk__in=[task.pk for task in undeped_tasks]).update(dependencies_processed=True)
|
UnifiedJob.objects.filter(pk__in=[task.pk for task in undeped_tasks]).update(dependencies_processed=True)
|
||||||
|
|
||||||
return created_dependencies
|
return created_dependencies
|
||||||
|
|
||||||
|
def process_tasks(self):
|
||||||
|
deps = self.generate_dependencies(self.all_tasks)
|
||||||
|
self.generate_dependencies(deps)
|
||||||
|
self.subsystem_metrics.inc(f"{self.prefix}_pending_processed", len(self.all_tasks) + len(deps))
|
||||||
|
|
||||||
|
@timeit
|
||||||
|
def _schedule(self):
|
||||||
|
self.get_tasks(dict(status__in=["pending"], dependencies_processed=False))
|
||||||
|
|
||||||
|
if len(self.all_tasks) > 0:
|
||||||
|
self.get_inventory_source_tasks()
|
||||||
|
self.process_tasks()
|
||||||
|
ScheduleTaskManager().schedule()
|
||||||
|
|
||||||
|
|
||||||
|
class TaskManager(TaskBase):
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
Do NOT put database queries or other potentially expensive operations
|
||||||
|
in the task manager init. The task manager object is created every time a
|
||||||
|
job is created, transitions state, and every 30 seconds on each tower node.
|
||||||
|
More often then not, the object is destroyed quickly because the NOOP case is hit.
|
||||||
|
|
||||||
|
The NOOP case is short-circuit logic. If the task manager realizes that another instance
|
||||||
|
of the task manager is already running, then it short-circuits and decides not to run.
|
||||||
|
"""
|
||||||
|
# start task limit indicates how many pending jobs can be started on this
|
||||||
|
# .schedule() run. Starting jobs is expensive, and there is code in place to reap
|
||||||
|
# the task manager after 5 minutes. At scale, the task manager can easily take more than
|
||||||
|
# 5 minutes to start pending jobs. If this limit is reached, pending jobs
|
||||||
|
# will no longer be started and will be started on the next task manager cycle.
|
||||||
|
self.time_delta_job_explanation = timedelta(seconds=30)
|
||||||
|
super().__init__(prefix="task_manager")
|
||||||
|
|
||||||
|
def after_lock_init(self):
|
||||||
|
"""
|
||||||
|
Init AFTER we know this instance of the task manager will run because the lock is acquired.
|
||||||
|
"""
|
||||||
|
self.dependency_graph = DependencyGraph()
|
||||||
|
self.instances = TaskManagerInstances(self.all_tasks)
|
||||||
|
self.instance_groups = TaskManagerInstanceGroups(instances_by_hostname=self.instances)
|
||||||
|
self.controlplane_ig = self.instance_groups.controlplane_ig
|
||||||
|
|
||||||
|
def job_blocked_by(self, task):
|
||||||
|
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
|
||||||
|
# in the old task manager this was handled as a method on each task object outside of the graph and
|
||||||
|
# probably has the side effect of cutting down *a lot* of the logic from this task manager class
|
||||||
|
blocked_by = self.dependency_graph.task_blocked_by(task)
|
||||||
|
if blocked_by:
|
||||||
|
return blocked_by
|
||||||
|
|
||||||
|
for dep in task.dependent_jobs.all():
|
||||||
|
if dep.status in ACTIVE_STATES:
|
||||||
|
return dep
|
||||||
|
# if we detect a failed or error dependency, go ahead and fail this
|
||||||
|
# task. The errback on the dependency takes some time to trigger,
|
||||||
|
# and we don't want the task to enter running state if its
|
||||||
|
# dependency has failed or errored.
|
||||||
|
elif dep.status in ("error", "failed"):
|
||||||
|
task.status = 'failed'
|
||||||
|
task.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % (
|
||||||
|
get_type_for_model(type(dep)),
|
||||||
|
dep.name,
|
||||||
|
dep.id,
|
||||||
|
)
|
||||||
|
task.save(update_fields=['status', 'job_explanation'])
|
||||||
|
task.websocket_emit_status('failed')
|
||||||
|
return dep
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@timeit
|
||||||
|
def start_task(self, task, instance_group, dependent_tasks=None, instance=None):
|
||||||
|
self.dependency_graph.add_job(task)
|
||||||
|
self.subsystem_metrics.inc(f"{self.prefix}_tasks_started", 1)
|
||||||
|
self.start_task_limit -= 1
|
||||||
|
if self.start_task_limit == 0:
|
||||||
|
# schedule another run immediately after this task manager
|
||||||
|
ScheduleTaskManager().schedule()
|
||||||
|
from awx.main.tasks.system import handle_work_error, handle_work_success
|
||||||
|
|
||||||
|
# update capacity for control node and execution node
|
||||||
|
if task.controller_node:
|
||||||
|
self.instances[task.controller_node].consume_capacity(settings.AWX_CONTROL_NODE_TASK_IMPACT)
|
||||||
|
if task.execution_node:
|
||||||
|
self.instances[task.execution_node].consume_capacity(task.task_impact)
|
||||||
|
|
||||||
|
dependent_tasks = dependent_tasks or []
|
||||||
|
|
||||||
|
task_actual = {
|
||||||
|
'type': get_type_for_model(type(task)),
|
||||||
|
'id': task.id,
|
||||||
|
}
|
||||||
|
dependencies = [{'type': get_type_for_model(type(t)), 'id': t.id} for t in dependent_tasks]
|
||||||
|
|
||||||
|
task.status = 'waiting'
|
||||||
|
|
||||||
|
(start_status, opts) = task.pre_start()
|
||||||
|
if not start_status:
|
||||||
|
task.status = 'failed'
|
||||||
|
if task.job_explanation:
|
||||||
|
task.job_explanation += ' '
|
||||||
|
task.job_explanation += 'Task failed pre-start check.'
|
||||||
|
task.save()
|
||||||
|
# TODO: run error handler to fail sub-tasks and send notifications
|
||||||
|
else:
|
||||||
|
if type(task) is WorkflowJob:
|
||||||
|
task.status = 'running'
|
||||||
|
task.send_notification_templates('running')
|
||||||
|
logger.debug('Transitioning %s to running status.', task.log_format)
|
||||||
|
# Call this to ensure Workflow nodes get spawned in timely manner
|
||||||
|
ScheduleWorkflowManager().schedule()
|
||||||
|
# at this point we already have control/execution nodes selected for the following cases
|
||||||
|
else:
|
||||||
|
task.instance_group = instance_group
|
||||||
|
execution_node_msg = f' and execution node {task.execution_node}' if task.execution_node else ''
|
||||||
|
logger.debug(
|
||||||
|
f'Submitting job {task.log_format} controlled by {task.controller_node} to instance group {instance_group.name}{execution_node_msg}.'
|
||||||
|
)
|
||||||
|
with disable_activity_stream():
|
||||||
|
task.celery_task_id = str(uuid.uuid4())
|
||||||
|
task.save()
|
||||||
|
task.log_lifecycle("waiting")
|
||||||
|
|
||||||
|
# apply_async does a NOTIFY to the channel dispatcher is listening to
|
||||||
|
# postgres will treat this as part of the transaction, which is what we want
|
||||||
|
if task.status != 'failed' and type(task) is not WorkflowJob:
|
||||||
|
task_cls = task._get_task_class()
|
||||||
|
task_cls.apply_async(
|
||||||
|
[task.pk],
|
||||||
|
opts,
|
||||||
|
queue=task.get_queue_name(),
|
||||||
|
uuid=task.celery_task_id,
|
||||||
|
callbacks=[{'task': handle_work_success.name, 'kwargs': {'task_actual': task_actual}}],
|
||||||
|
errbacks=[{'task': handle_work_error.name, 'args': [task.celery_task_id], 'kwargs': {'subtasks': [task_actual] + dependencies}}],
|
||||||
|
)
|
||||||
|
|
||||||
|
# In exception cases, like a job failing pre-start checks, we send the websocket status message
|
||||||
|
# for jobs going into waiting, we omit this because of performance issues, as it should go to running quickly
|
||||||
|
if task.status != 'waiting':
|
||||||
|
task.websocket_emit_status(task.status) # adds to on_commit
|
||||||
|
|
||||||
|
@timeit
|
||||||
|
def process_running_tasks(self, running_tasks):
|
||||||
|
for task in running_tasks:
|
||||||
|
if type(task) is WorkflowJob:
|
||||||
|
ScheduleWorkflowManager().schedule()
|
||||||
|
self.dependency_graph.add_job(task)
|
||||||
|
|
||||||
|
@timeit
|
||||||
def process_pending_tasks(self, pending_tasks):
|
def process_pending_tasks(self, pending_tasks):
|
||||||
running_workflow_templates = {wf.unified_job_template_id for wf in self.get_running_workflow_jobs()}
|
|
||||||
tasks_to_update_job_explanation = []
|
tasks_to_update_job_explanation = []
|
||||||
for task in pending_tasks:
|
for task in pending_tasks:
|
||||||
if self.start_task_limit <= 0:
|
if self.start_task_limit <= 0:
|
||||||
break
|
break
|
||||||
|
if self.timed_out():
|
||||||
|
logger.warning("Task manager has reached time out while processing pending jobs, exiting loop early")
|
||||||
|
break
|
||||||
blocked_by = self.job_blocked_by(task)
|
blocked_by = self.job_blocked_by(task)
|
||||||
if blocked_by:
|
if blocked_by:
|
||||||
|
self.subsystem_metrics.inc(f"{self.prefix}_tasks_blocked", 1)
|
||||||
task.log_lifecycle("blocked", blocked_by=blocked_by)
|
task.log_lifecycle("blocked", blocked_by=blocked_by)
|
||||||
job_explanation = gettext_noop(f"waiting for {blocked_by._meta.model_name}-{blocked_by.id} to finish")
|
job_explanation = gettext_noop(f"waiting for {blocked_by._meta.model_name}-{blocked_by.id} to finish")
|
||||||
if task.job_explanation != job_explanation:
|
if task.job_explanation != job_explanation:
|
||||||
@@ -433,19 +601,16 @@ class TaskManager:
|
|||||||
tasks_to_update_job_explanation.append(task)
|
tasks_to_update_job_explanation.append(task)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
found_acceptable_queue = False
|
|
||||||
preferred_instance_groups = task.preferred_instance_groups
|
|
||||||
|
|
||||||
if isinstance(task, WorkflowJob):
|
if isinstance(task, WorkflowJob):
|
||||||
if task.unified_job_template_id in running_workflow_templates:
|
# Previously we were tracking allow_simultaneous blocking both here and in DependencyGraph.
|
||||||
if not task.allow_simultaneous:
|
# Double check that using just the DependencyGraph works for Workflows and Sliced Jobs.
|
||||||
logger.debug("{} is blocked from running, workflow already running".format(task.log_format))
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
running_workflow_templates.add(task.unified_job_template_id)
|
|
||||||
self.start_task(task, None, task.get_jobs_fail_chain(), None)
|
self.start_task(task, None, task.get_jobs_fail_chain(), None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
found_acceptable_queue = False
|
||||||
|
|
||||||
|
preferred_instance_groups = self.instance_groups.get_instance_groups_from_task_cache(task)
|
||||||
|
|
||||||
# Determine if there is control capacity for the task
|
# Determine if there is control capacity for the task
|
||||||
if task.capacity_type == 'control':
|
if task.capacity_type == 'control':
|
||||||
control_impact = task.task_impact + settings.AWX_CONTROL_NODE_TASK_IMPACT
|
control_impact = task.task_impact + settings.AWX_CONTROL_NODE_TASK_IMPACT
|
||||||
@@ -464,8 +629,6 @@ class TaskManager:
|
|||||||
# All task.capacity_type == 'control' jobs should run on control plane, no need to loop over instance groups
|
# All task.capacity_type == 'control' jobs should run on control plane, no need to loop over instance groups
|
||||||
if task.capacity_type == 'control':
|
if task.capacity_type == 'control':
|
||||||
task.execution_node = control_instance.hostname
|
task.execution_node = control_instance.hostname
|
||||||
control_instance.consume_capacity(control_impact)
|
|
||||||
self.dependency_graph.add_job(task)
|
|
||||||
execution_instance = self.instances[control_instance.hostname].obj
|
execution_instance = self.instances[control_instance.hostname].obj
|
||||||
task.log_lifecycle("controller_node_chosen")
|
task.log_lifecycle("controller_node_chosen")
|
||||||
task.log_lifecycle("execution_node_chosen")
|
task.log_lifecycle("execution_node_chosen")
|
||||||
@@ -475,7 +638,6 @@ class TaskManager:
|
|||||||
|
|
||||||
for instance_group in preferred_instance_groups:
|
for instance_group in preferred_instance_groups:
|
||||||
if instance_group.is_container_group:
|
if instance_group.is_container_group:
|
||||||
self.dependency_graph.add_job(task)
|
|
||||||
self.start_task(task, instance_group, task.get_jobs_fail_chain(), None)
|
self.start_task(task, instance_group, task.get_jobs_fail_chain(), None)
|
||||||
found_acceptable_queue = True
|
found_acceptable_queue = True
|
||||||
break
|
break
|
||||||
@@ -497,9 +659,7 @@ class TaskManager:
|
|||||||
control_instance = execution_instance
|
control_instance = execution_instance
|
||||||
task.controller_node = execution_instance.hostname
|
task.controller_node = execution_instance.hostname
|
||||||
|
|
||||||
control_instance.consume_capacity(settings.AWX_CONTROL_NODE_TASK_IMPACT)
|
|
||||||
task.log_lifecycle("controller_node_chosen")
|
task.log_lifecycle("controller_node_chosen")
|
||||||
execution_instance.consume_capacity(task.task_impact)
|
|
||||||
task.log_lifecycle("execution_node_chosen")
|
task.log_lifecycle("execution_node_chosen")
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Starting {} in group {} instance {} (remaining_capacity={})".format(
|
"Starting {} in group {} instance {} (remaining_capacity={})".format(
|
||||||
@@ -507,7 +667,6 @@ class TaskManager:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
execution_instance = self.instances[execution_instance.hostname].obj
|
execution_instance = self.instances[execution_instance.hostname].obj
|
||||||
self.dependency_graph.add_job(task)
|
|
||||||
self.start_task(task, instance_group, task.get_jobs_fail_chain(), execution_instance)
|
self.start_task(task, instance_group, task.get_jobs_fail_chain(), execution_instance)
|
||||||
found_acceptable_queue = True
|
found_acceptable_queue = True
|
||||||
break
|
break
|
||||||
@@ -533,25 +692,6 @@ class TaskManager:
|
|||||||
tasks_to_update_job_explanation.append(task)
|
tasks_to_update_job_explanation.append(task)
|
||||||
logger.debug("{} couldn't be scheduled on graph, waiting for next cycle".format(task.log_format))
|
logger.debug("{} couldn't be scheduled on graph, waiting for next cycle".format(task.log_format))
|
||||||
|
|
||||||
def timeout_approval_node(self):
|
|
||||||
workflow_approvals = WorkflowApproval.objects.filter(status='pending')
|
|
||||||
now = tz_now()
|
|
||||||
for task in workflow_approvals:
|
|
||||||
approval_timeout_seconds = timedelta(seconds=task.timeout)
|
|
||||||
if task.timeout == 0:
|
|
||||||
continue
|
|
||||||
if (now - task.created) >= approval_timeout_seconds:
|
|
||||||
timeout_message = _("The approval node {name} ({pk}) has expired after {timeout} seconds.").format(
|
|
||||||
name=task.name, pk=task.pk, timeout=task.timeout
|
|
||||||
)
|
|
||||||
logger.warning(timeout_message)
|
|
||||||
task.timed_out = True
|
|
||||||
task.status = 'failed'
|
|
||||||
task.send_approval_notification('timed_out')
|
|
||||||
task.websocket_emit_status(task.status)
|
|
||||||
task.job_explanation = timeout_message
|
|
||||||
task.save(update_fields=['status', 'job_explanation', 'timed_out'])
|
|
||||||
|
|
||||||
def reap_jobs_from_orphaned_instances(self):
|
def reap_jobs_from_orphaned_instances(self):
|
||||||
# discover jobs that are in running state but aren't on an execution node
|
# discover jobs that are in running state but aren't on an execution node
|
||||||
# that we know about; this is a fairly rare event, but it can occur if you,
|
# that we know about; this is a fairly rare event, but it can occur if you,
|
||||||
@@ -564,60 +704,45 @@ class TaskManager:
|
|||||||
logger.error(f'{j.execution_node} is not a registered instance; reaping {j.log_format}')
|
logger.error(f'{j.execution_node} is not a registered instance; reaping {j.log_format}')
|
||||||
reap_job(j, 'failed')
|
reap_job(j, 'failed')
|
||||||
|
|
||||||
def process_tasks(self, all_sorted_tasks):
|
def process_tasks(self):
|
||||||
running_tasks = [t for t in all_sorted_tasks if t.status in ['waiting', 'running']]
|
running_tasks = [t for t in self.all_tasks if t.status in ['waiting', 'running']]
|
||||||
|
|
||||||
self.process_running_tasks(running_tasks)
|
self.process_running_tasks(running_tasks)
|
||||||
|
self.subsystem_metrics.inc(f"{self.prefix}_running_processed", len(running_tasks))
|
||||||
|
|
||||||
|
pending_tasks = [t for t in self.all_tasks if t.status == 'pending']
|
||||||
|
|
||||||
pending_tasks = [t for t in all_sorted_tasks if t.status == 'pending']
|
|
||||||
undeped_tasks = [t for t in pending_tasks if not t.dependencies_processed]
|
|
||||||
dependencies = self.generate_dependencies(undeped_tasks)
|
|
||||||
self.process_pending_tasks(dependencies)
|
|
||||||
self.process_pending_tasks(pending_tasks)
|
self.process_pending_tasks(pending_tasks)
|
||||||
|
self.subsystem_metrics.inc(f"{self.prefix}_pending_processed", len(pending_tasks))
|
||||||
|
|
||||||
|
def timeout_approval_node(self, task):
|
||||||
|
if self.timed_out():
|
||||||
|
logger.warning("Task manager has reached time out while processing approval nodes, exiting loop early")
|
||||||
|
# Do not process any more workflow approval nodes. Stop here.
|
||||||
|
# Maybe we should schedule another TaskManager run
|
||||||
|
return
|
||||||
|
timeout_message = _("The approval node {name} ({pk}) has expired after {timeout} seconds.").format(name=task.name, pk=task.pk, timeout=task.timeout)
|
||||||
|
logger.warning(timeout_message)
|
||||||
|
task.timed_out = True
|
||||||
|
task.status = 'failed'
|
||||||
|
task.send_approval_notification('timed_out')
|
||||||
|
task.websocket_emit_status(task.status)
|
||||||
|
task.job_explanation = timeout_message
|
||||||
|
task.save(update_fields=['status', 'job_explanation', 'timed_out'])
|
||||||
|
|
||||||
|
def get_expired_workflow_approvals(self):
|
||||||
|
# timeout of 0 indicates that it never expires
|
||||||
|
qs = WorkflowApproval.objects.filter(status='pending').exclude(timeout=0).filter(expires__lt=tz_now())
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@timeit
|
||||||
def _schedule(self):
|
def _schedule(self):
|
||||||
finished_wfjs = []
|
self.get_tasks(dict(status__in=["pending", "waiting", "running"], dependencies_processed=True))
|
||||||
all_sorted_tasks = self.get_tasks()
|
|
||||||
|
|
||||||
self.after_lock_init(all_sorted_tasks)
|
self.after_lock_init()
|
||||||
|
self.reap_jobs_from_orphaned_instances()
|
||||||
|
|
||||||
if len(all_sorted_tasks) > 0:
|
if len(self.all_tasks) > 0:
|
||||||
# TODO: Deal with
|
self.process_tasks()
|
||||||
# latest_project_updates = self.get_latest_project_update_tasks(all_sorted_tasks)
|
|
||||||
# self.process_latest_project_updates(latest_project_updates)
|
|
||||||
|
|
||||||
# latest_inventory_updates = self.get_latest_inventory_update_tasks(all_sorted_tasks)
|
for workflow_approval in self.get_expired_workflow_approvals():
|
||||||
# self.process_latest_inventory_updates(latest_inventory_updates)
|
self.timeout_approval_node(workflow_approval)
|
||||||
|
|
||||||
self.all_inventory_sources = self.get_inventory_source_tasks(all_sorted_tasks)
|
|
||||||
|
|
||||||
running_workflow_tasks = self.get_running_workflow_jobs()
|
|
||||||
finished_wfjs = self.process_finished_workflow_jobs(running_workflow_tasks)
|
|
||||||
|
|
||||||
previously_running_workflow_tasks = running_workflow_tasks
|
|
||||||
running_workflow_tasks = []
|
|
||||||
for workflow_job in previously_running_workflow_tasks:
|
|
||||||
if workflow_job.status == 'running':
|
|
||||||
running_workflow_tasks.append(workflow_job)
|
|
||||||
else:
|
|
||||||
logger.debug('Removed %s from job spawning consideration.', workflow_job.log_format)
|
|
||||||
|
|
||||||
self.spawn_workflow_graph_jobs(running_workflow_tasks)
|
|
||||||
|
|
||||||
self.timeout_approval_node()
|
|
||||||
self.reap_jobs_from_orphaned_instances()
|
|
||||||
|
|
||||||
self.process_tasks(all_sorted_tasks)
|
|
||||||
return finished_wfjs
|
|
||||||
|
|
||||||
def schedule(self):
|
|
||||||
# Lock
|
|
||||||
with advisory_lock('task_manager_lock', wait=False) as acquired:
|
|
||||||
with transaction.atomic():
|
|
||||||
if acquired is False:
|
|
||||||
logger.debug("Not running scheduler, another task holds lock")
|
|
||||||
return
|
|
||||||
logger.debug("Starting Scheduler")
|
|
||||||
with task_manager_bulk_reschedule():
|
|
||||||
self._schedule()
|
|
||||||
logger.debug("Finishing Scheduler")
|
|
||||||
|
|||||||
@@ -34,12 +34,10 @@ class TaskManagerInstance:
|
|||||||
|
|
||||||
|
|
||||||
class TaskManagerInstances:
|
class TaskManagerInstances:
|
||||||
def __init__(self, active_tasks, instances=None):
|
def __init__(self, active_tasks, instances=None, instance_fields=('node_type', 'capacity', 'hostname', 'enabled')):
|
||||||
self.instances_by_hostname = dict()
|
self.instances_by_hostname = dict()
|
||||||
if instances is None:
|
if instances is None:
|
||||||
instances = (
|
instances = Instance.objects.filter(hostname__isnull=False, enabled=True).exclude(node_type='hop').only(*instance_fields)
|
||||||
Instance.objects.filter(hostname__isnull=False, enabled=True).exclude(node_type='hop').only('node_type', 'capacity', 'hostname', 'enabled')
|
|
||||||
)
|
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
self.instances_by_hostname[instance.hostname] = TaskManagerInstance(instance)
|
self.instances_by_hostname[instance.hostname] = TaskManagerInstance(instance)
|
||||||
|
|
||||||
@@ -67,6 +65,7 @@ class TaskManagerInstanceGroups:
|
|||||||
def __init__(self, instances_by_hostname=None, instance_groups=None, instance_groups_queryset=None):
|
def __init__(self, instances_by_hostname=None, instance_groups=None, instance_groups_queryset=None):
|
||||||
self.instance_groups = dict()
|
self.instance_groups = dict()
|
||||||
self.controlplane_ig = None
|
self.controlplane_ig = None
|
||||||
|
self.pk_ig_map = dict()
|
||||||
|
|
||||||
if instance_groups is not None: # for testing
|
if instance_groups is not None: # for testing
|
||||||
self.instance_groups = instance_groups
|
self.instance_groups = instance_groups
|
||||||
@@ -81,6 +80,7 @@ class TaskManagerInstanceGroups:
|
|||||||
instances_by_hostname[instance.hostname] for instance in instance_group.instances.all() if instance.hostname in instances_by_hostname
|
instances_by_hostname[instance.hostname] for instance in instance_group.instances.all() if instance.hostname in instances_by_hostname
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
self.pk_ig_map[instance_group.pk] = instance_group
|
||||||
|
|
||||||
def get_remaining_capacity(self, group_name):
|
def get_remaining_capacity(self, group_name):
|
||||||
instances = self.instance_groups[group_name]['instances']
|
instances = self.instance_groups[group_name]['instances']
|
||||||
@@ -121,3 +121,17 @@ class TaskManagerInstanceGroups:
|
|||||||
elif i.capacity > largest_instance.capacity:
|
elif i.capacity > largest_instance.capacity:
|
||||||
largest_instance = i
|
largest_instance = i
|
||||||
return largest_instance
|
return largest_instance
|
||||||
|
|
||||||
|
def get_instance_groups_from_task_cache(self, task):
|
||||||
|
igs = []
|
||||||
|
if task.preferred_instance_groups_cache:
|
||||||
|
for pk in task.preferred_instance_groups_cache:
|
||||||
|
ig = self.pk_ig_map.get(pk, None)
|
||||||
|
if ig:
|
||||||
|
igs.append(ig)
|
||||||
|
else:
|
||||||
|
logger.warn(f"Unknown instance group with pk {pk} for task {task}")
|
||||||
|
if len(igs) == 0:
|
||||||
|
logger.warn(f"No instance groups in cache exist, defaulting to global instance groups for task {task}")
|
||||||
|
return task.global_instance_groups
|
||||||
|
return igs
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
# Python
|
# Python
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
# Django
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.scheduler import TaskManager
|
from awx import MODE
|
||||||
|
from awx.main.scheduler import TaskManager, DependencyManager, WorkflowManager
|
||||||
from awx.main.dispatch.publish import task
|
from awx.main.dispatch.publish import task
|
||||||
from awx.main.dispatch import get_local_queuename
|
from awx.main.dispatch import get_local_queuename
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.scheduler')
|
logger = logging.getLogger('awx.main.scheduler')
|
||||||
|
|
||||||
|
|
||||||
|
def run_manager(manager, prefix):
|
||||||
|
if MODE == 'development' and settings.AWX_DISABLE_TASK_MANAGERS:
|
||||||
|
logger.debug(f"Not running {prefix} manager, AWX_DISABLE_TASK_MANAGERS is True. Trigger with GET to /api/debug/{prefix}_manager/")
|
||||||
|
return
|
||||||
|
manager().schedule()
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
@task(queue=get_local_queuename)
|
||||||
def run_task_manager():
|
def task_manager():
|
||||||
logger.debug("Running task manager.")
|
run_manager(TaskManager, "task")
|
||||||
TaskManager().schedule()
|
|
||||||
|
|
||||||
|
@task(queue=get_local_queuename)
|
||||||
|
def dependency_manager():
|
||||||
|
run_manager(DependencyManager, "dependency")
|
||||||
|
|
||||||
|
|
||||||
|
@task(queue=get_local_queuename)
|
||||||
|
def workflow_manager():
|
||||||
|
run_manager(WorkflowManager, "workflow")
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ def emit_activity_stream_change(instance):
|
|||||||
from awx.api.serializers import ActivityStreamSerializer
|
from awx.api.serializers import ActivityStreamSerializer
|
||||||
|
|
||||||
actor = None
|
actor = None
|
||||||
if instance.actor:
|
if instance.actor_id:
|
||||||
actor = instance.actor.username
|
actor = instance.actor.username
|
||||||
summary_fields = ActivityStreamSerializer(instance).get_summary_fields(instance)
|
summary_fields = ActivityStreamSerializer(instance).get_summary_fields(instance)
|
||||||
analytics_logger.info(
|
analytics_logger.info(
|
||||||
|
|||||||
@@ -9,19 +9,19 @@ import stat
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django_guid import get_guid
|
from django_guid import get_guid
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.redact import UriCleaner
|
from awx.main.redact import UriCleaner
|
||||||
from awx.main.constants import MINIMAL_EVENTS
|
from awx.main.constants import MINIMAL_EVENTS, ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE
|
||||||
from awx.main.utils.update_model import update_model
|
from awx.main.utils.update_model import update_model
|
||||||
from awx.main.queue import CallbackQueueDispatcher
|
from awx.main.queue import CallbackQueueDispatcher
|
||||||
|
from awx.main.tasks.signals import signal_callback
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.tasks.callback')
|
logger = logging.getLogger('awx.main.tasks.callback')
|
||||||
|
|
||||||
|
|
||||||
class RunnerCallback:
|
class RunnerCallback:
|
||||||
event_data_key = 'job_id'
|
|
||||||
|
|
||||||
def __init__(self, model=None):
|
def __init__(self, model=None):
|
||||||
self.parent_workflow_job_id = None
|
self.parent_workflow_job_id = None
|
||||||
self.host_map = {}
|
self.host_map = {}
|
||||||
@@ -33,10 +33,40 @@ class RunnerCallback:
|
|||||||
self.event_ct = 0
|
self.event_ct = 0
|
||||||
self.model = model
|
self.model = model
|
||||||
self.update_attempts = int(settings.DISPATCHER_DB_DOWNTOWN_TOLLERANCE / 5)
|
self.update_attempts = int(settings.DISPATCHER_DB_DOWNTOWN_TOLLERANCE / 5)
|
||||||
|
self.wrapup_event_dispatched = False
|
||||||
|
self.extra_update_fields = {}
|
||||||
|
|
||||||
def update_model(self, pk, _attempt=0, **updates):
|
def update_model(self, pk, _attempt=0, **updates):
|
||||||
return update_model(self.model, pk, _attempt=0, _max_attempts=self.update_attempts, **updates)
|
return update_model(self.model, pk, _attempt=0, _max_attempts=self.update_attempts, **updates)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def wrapup_event_type(self):
|
||||||
|
return self.instance.event_class.WRAPUP_EVENT
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def event_data_key(self):
|
||||||
|
return self.instance.event_class.JOB_REFERENCE
|
||||||
|
|
||||||
|
def delay_update(self, skip_if_already_set=False, **kwargs):
|
||||||
|
"""Stash fields that should be saved along with the job status change"""
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if key in self.extra_update_fields and skip_if_already_set:
|
||||||
|
continue
|
||||||
|
elif key in self.extra_update_fields and key in ('job_explanation', 'result_traceback'):
|
||||||
|
if str(value) in self.extra_update_fields.get(key, ''):
|
||||||
|
continue # if already set, avoid duplicating messages
|
||||||
|
# In the case of these fields, we do not want to lose any prior information, so combine values
|
||||||
|
self.extra_update_fields[key] = '\n'.join([str(self.extra_update_fields[key]), str(value)])
|
||||||
|
else:
|
||||||
|
self.extra_update_fields[key] = value
|
||||||
|
|
||||||
|
def get_delayed_update_fields(self):
|
||||||
|
"""Return finalized dict of all fields that should be saved along with the job status change"""
|
||||||
|
self.extra_update_fields['emitted_events'] = self.event_ct
|
||||||
|
if 'got an unexpected keyword argument' in self.extra_update_fields.get('result_traceback', ''):
|
||||||
|
self.delay_update(result_traceback=ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE)
|
||||||
|
return self.extra_update_fields
|
||||||
|
|
||||||
def event_handler(self, event_data):
|
def event_handler(self, event_data):
|
||||||
#
|
#
|
||||||
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
||||||
@@ -130,6 +160,9 @@ class RunnerCallback:
|
|||||||
elif self.recent_event_timings.maxlen:
|
elif self.recent_event_timings.maxlen:
|
||||||
self.recent_event_timings.append(time.time())
|
self.recent_event_timings.append(time.time())
|
||||||
|
|
||||||
|
if event_data.get('event', '') == self.wrapup_event_type:
|
||||||
|
self.wrapup_event_dispatched = True
|
||||||
|
|
||||||
event_data.setdefault(self.event_data_key, self.instance.id)
|
event_data.setdefault(self.event_data_key, self.instance.id)
|
||||||
self.dispatcher.dispatch(event_data)
|
self.dispatcher.dispatch(event_data)
|
||||||
self.event_ct += 1
|
self.event_ct += 1
|
||||||
@@ -138,8 +171,7 @@ class RunnerCallback:
|
|||||||
Handle artifacts
|
Handle artifacts
|
||||||
'''
|
'''
|
||||||
if event_data.get('event_data', {}).get('artifact_data', {}):
|
if event_data.get('event_data', {}).get('artifact_data', {}):
|
||||||
self.instance.artifacts = event_data['event_data']['artifact_data']
|
self.delay_update(artifacts=event_data['event_data']['artifact_data'])
|
||||||
self.instance.save(update_fields=['artifacts'])
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -148,7 +180,13 @@ class RunnerCallback:
|
|||||||
Ansible runner callback to tell the job when/if it is canceled
|
Ansible runner callback to tell the job when/if it is canceled
|
||||||
"""
|
"""
|
||||||
unified_job_id = self.instance.pk
|
unified_job_id = self.instance.pk
|
||||||
self.instance = self.update_model(unified_job_id)
|
if signal_callback():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
self.instance = self.update_model(unified_job_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f'Encountered error during cancel check for {unified_job_id}, canceling now')
|
||||||
|
return True
|
||||||
if not self.instance:
|
if not self.instance:
|
||||||
logger.error('unified job {} was deleted while running, canceling'.format(unified_job_id))
|
logger.error('unified job {} was deleted while running, canceling'.format(unified_job_id))
|
||||||
return True
|
return True
|
||||||
@@ -170,6 +208,8 @@ class RunnerCallback:
|
|||||||
}
|
}
|
||||||
event_data.setdefault(self.event_data_key, self.instance.id)
|
event_data.setdefault(self.event_data_key, self.instance.id)
|
||||||
self.dispatcher.dispatch(event_data)
|
self.dispatcher.dispatch(event_data)
|
||||||
|
if self.wrapup_event_type == 'EOF':
|
||||||
|
self.wrapup_event_dispatched = True
|
||||||
|
|
||||||
def status_handler(self, status_data, runner_config):
|
def status_handler(self, status_data, runner_config):
|
||||||
"""
|
"""
|
||||||
@@ -205,16 +245,10 @@ class RunnerCallback:
|
|||||||
elif status_data['status'] == 'error':
|
elif status_data['status'] == 'error':
|
||||||
result_traceback = status_data.get('result_traceback', None)
|
result_traceback = status_data.get('result_traceback', None)
|
||||||
if result_traceback:
|
if result_traceback:
|
||||||
from awx.main.signals import disable_activity_stream # Circular import
|
self.delay_update(result_traceback=result_traceback)
|
||||||
|
|
||||||
with disable_activity_stream():
|
|
||||||
self.instance = self.update_model(self.instance.pk, result_traceback=result_traceback)
|
|
||||||
|
|
||||||
|
|
||||||
class RunnerCallbackForProjectUpdate(RunnerCallback):
|
class RunnerCallbackForProjectUpdate(RunnerCallback):
|
||||||
|
|
||||||
event_data_key = 'project_update_id'
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(RunnerCallbackForProjectUpdate, self).__init__(*args, **kwargs)
|
super(RunnerCallbackForProjectUpdate, self).__init__(*args, **kwargs)
|
||||||
self.playbook_new_revision = None
|
self.playbook_new_revision = None
|
||||||
@@ -231,9 +265,6 @@ class RunnerCallbackForProjectUpdate(RunnerCallback):
|
|||||||
|
|
||||||
|
|
||||||
class RunnerCallbackForInventoryUpdate(RunnerCallback):
|
class RunnerCallbackForInventoryUpdate(RunnerCallback):
|
||||||
|
|
||||||
event_data_key = 'inventory_update_id'
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(RunnerCallbackForInventoryUpdate, self).__init__(*args, **kwargs)
|
super(RunnerCallbackForInventoryUpdate, self).__init__(*args, **kwargs)
|
||||||
self.end_line = 0
|
self.end_line = 0
|
||||||
@@ -245,9 +276,6 @@ class RunnerCallbackForInventoryUpdate(RunnerCallback):
|
|||||||
|
|
||||||
|
|
||||||
class RunnerCallbackForAdHocCommand(RunnerCallback):
|
class RunnerCallbackForAdHocCommand(RunnerCallback):
|
||||||
|
|
||||||
event_data_key = 'ad_hoc_command_id'
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(RunnerCallbackForAdHocCommand, self).__init__(*args, **kwargs)
|
super(RunnerCallbackForAdHocCommand, self).__init__(*args, **kwargs)
|
||||||
self.host_map = {}
|
self.host_map = {}
|
||||||
@@ -255,4 +283,4 @@ class RunnerCallbackForAdHocCommand(RunnerCallback):
|
|||||||
|
|
||||||
class RunnerCallbackForSystemJob(RunnerCallback):
|
class RunnerCallbackForSystemJob(RunnerCallback):
|
||||||
|
|
||||||
event_data_key = 'system_job_id'
|
pass
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Python
|
# Python
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from distutils.dir_util import copy_tree
|
|
||||||
import errno
|
import errno
|
||||||
import functools
|
import functools
|
||||||
import fcntl
|
import fcntl
|
||||||
@@ -15,11 +14,9 @@ import tempfile
|
|||||||
import traceback
|
import traceback
|
||||||
import time
|
import time
|
||||||
import urllib.parse as urlparse
|
import urllib.parse as urlparse
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
|
|
||||||
# Runner
|
# Runner
|
||||||
@@ -34,13 +31,12 @@ from gitdb.exc import BadName as BadGitName
|
|||||||
from awx.main.dispatch.publish import task
|
from awx.main.dispatch.publish import task
|
||||||
from awx.main.dispatch import get_local_queuename
|
from awx.main.dispatch import get_local_queuename
|
||||||
from awx.main.constants import (
|
from awx.main.constants import (
|
||||||
ACTIVE_STATES,
|
|
||||||
PRIVILEGE_ESCALATION_METHODS,
|
PRIVILEGE_ESCALATION_METHODS,
|
||||||
STANDARD_INVENTORY_UPDATE_ENV,
|
STANDARD_INVENTORY_UPDATE_ENV,
|
||||||
JOB_FOLDER_PREFIX,
|
JOB_FOLDER_PREFIX,
|
||||||
MAX_ISOLATED_PATH_COLON_DELIMITER,
|
MAX_ISOLATED_PATH_COLON_DELIMITER,
|
||||||
CONTAINER_VOLUMES_MOUNT_TYPES,
|
CONTAINER_VOLUMES_MOUNT_TYPES,
|
||||||
ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE,
|
ACTIVE_STATES,
|
||||||
)
|
)
|
||||||
from awx.main.models import (
|
from awx.main.models import (
|
||||||
Instance,
|
Instance,
|
||||||
@@ -65,6 +61,7 @@ from awx.main.tasks.callback import (
|
|||||||
RunnerCallbackForProjectUpdate,
|
RunnerCallbackForProjectUpdate,
|
||||||
RunnerCallbackForSystemJob,
|
RunnerCallbackForSystemJob,
|
||||||
)
|
)
|
||||||
|
from awx.main.tasks.signals import with_signal_handling, signal_callback
|
||||||
from awx.main.tasks.receptor import AWXReceptorJob
|
from awx.main.tasks.receptor import AWXReceptorJob
|
||||||
from awx.main.exceptions import AwxTaskError, PostRunError, ReceptorNodeNotFound
|
from awx.main.exceptions import AwxTaskError, PostRunError, ReceptorNodeNotFound
|
||||||
from awx.main.utils.ansible import read_ansible_config
|
from awx.main.utils.ansible import read_ansible_config
|
||||||
@@ -78,7 +75,7 @@ from awx.main.utils.common import (
|
|||||||
)
|
)
|
||||||
from awx.conf.license import get_license
|
from awx.conf.license import get_license
|
||||||
from awx.main.utils.handlers import SpecialInventoryHandler
|
from awx.main.utils.handlers import SpecialInventoryHandler
|
||||||
from awx.main.tasks.system import handle_success_and_failure_notifications, update_smart_memberships_for_inventory, update_inventory_computed_fields
|
from awx.main.tasks.system import update_smart_memberships_for_inventory, update_inventory_computed_fields
|
||||||
from awx.main.utils.update_model import update_model
|
from awx.main.utils.update_model import update_model
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -119,12 +116,11 @@ class BaseTask(object):
|
|||||||
def update_model(self, pk, _attempt=0, **updates):
|
def update_model(self, pk, _attempt=0, **updates):
|
||||||
return update_model(self.model, pk, _attempt=0, _max_attempts=self.update_attempts, **updates)
|
return update_model(self.model, pk, _attempt=0, _max_attempts=self.update_attempts, **updates)
|
||||||
|
|
||||||
def write_private_data_file(self, private_data_dir, file_name, data, sub_dir=None, permissions=0o600):
|
def write_private_data_file(self, private_data_dir, file_name, data, sub_dir=None, file_permissions=0o600):
|
||||||
base_path = private_data_dir
|
base_path = private_data_dir
|
||||||
if sub_dir:
|
if sub_dir:
|
||||||
base_path = os.path.join(private_data_dir, sub_dir)
|
base_path = os.path.join(private_data_dir, sub_dir)
|
||||||
if not os.path.exists(base_path):
|
os.makedirs(base_path, mode=0o700, exist_ok=True)
|
||||||
os.mkdir(base_path, 0o700)
|
|
||||||
|
|
||||||
# If we got a file name create it, otherwise we want a temp file
|
# If we got a file name create it, otherwise we want a temp file
|
||||||
if file_name:
|
if file_name:
|
||||||
@@ -134,7 +130,7 @@ class BaseTask(object):
|
|||||||
os.close(handle)
|
os.close(handle)
|
||||||
|
|
||||||
file = Path(file_path)
|
file = Path(file_path)
|
||||||
file.touch(mode=permissions, exist_ok=True)
|
file.touch(mode=file_permissions, exist_ok=True)
|
||||||
with open(file_path, 'w') as f:
|
with open(file_path, 'w') as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
return file_path
|
return file_path
|
||||||
@@ -214,14 +210,22 @@ class BaseTask(object):
|
|||||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
||||||
if settings.AWX_CLEANUP_PATHS:
|
if settings.AWX_CLEANUP_PATHS:
|
||||||
self.cleanup_paths.append(path)
|
self.cleanup_paths.append(path)
|
||||||
# Ansible runner requires that project exists,
|
# We will write files in these folders later
|
||||||
# and we will write files in the other folders without pre-creating the folder
|
for subfolder in ('inventory', 'env'):
|
||||||
for subfolder in ('project', 'inventory', 'env'):
|
|
||||||
runner_subfolder = os.path.join(path, subfolder)
|
runner_subfolder = os.path.join(path, subfolder)
|
||||||
if not os.path.exists(runner_subfolder):
|
if not os.path.exists(runner_subfolder):
|
||||||
os.mkdir(runner_subfolder)
|
os.mkdir(runner_subfolder)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def build_project_dir(self, instance, private_data_dir):
|
||||||
|
"""
|
||||||
|
Create the ansible-runner project subdirectory. In many cases this is the source checkout.
|
||||||
|
In cases that do not even need the source checkout, we create an empty dir to be the workdir.
|
||||||
|
"""
|
||||||
|
project_dir = os.path.join(private_data_dir, 'project')
|
||||||
|
if not os.path.exists(project_dir):
|
||||||
|
os.mkdir(project_dir)
|
||||||
|
|
||||||
def build_private_data_files(self, instance, private_data_dir):
|
def build_private_data_files(self, instance, private_data_dir):
|
||||||
"""
|
"""
|
||||||
Creates temporary files containing the private data.
|
Creates temporary files containing the private data.
|
||||||
@@ -257,9 +261,9 @@ class BaseTask(object):
|
|||||||
# Instead, ssh private key file is explicitly passed via an
|
# Instead, ssh private key file is explicitly passed via an
|
||||||
# env variable.
|
# env variable.
|
||||||
else:
|
else:
|
||||||
private_data_files['credentials'][credential] = self.write_private_data_file(private_data_dir, None, data, 'env')
|
private_data_files['credentials'][credential] = self.write_private_data_file(private_data_dir, None, data, sub_dir='env')
|
||||||
for credential, data in private_data.get('certificates', {}).items():
|
for credential, data in private_data.get('certificates', {}).items():
|
||||||
self.write_private_data_file(private_data_dir, 'ssh_key_data-cert.pub', data, 'artifacts')
|
self.write_private_data_file(private_data_dir, 'ssh_key_data-cert.pub', data, sub_dir=os.path.join('artifacts', str(self.instance.id)))
|
||||||
return private_data_files, ssh_key_data
|
return private_data_files, ssh_key_data
|
||||||
|
|
||||||
def build_passwords(self, instance, runtime_passwords):
|
def build_passwords(self, instance, runtime_passwords):
|
||||||
@@ -282,7 +286,7 @@ class BaseTask(object):
|
|||||||
content = yaml.safe_dump(vars)
|
content = yaml.safe_dump(vars)
|
||||||
else:
|
else:
|
||||||
content = safe_dump(vars, safe_dict)
|
content = safe_dump(vars, safe_dict)
|
||||||
return self.write_private_data_file(private_data_dir, 'extravars', content, 'env')
|
return self.write_private_data_file(private_data_dir, 'extravars', content, sub_dir='env')
|
||||||
|
|
||||||
def add_awx_venv(self, env):
|
def add_awx_venv(self, env):
|
||||||
env['VIRTUAL_ENV'] = settings.AWX_VENV_PATH
|
env['VIRTUAL_ENV'] = settings.AWX_VENV_PATH
|
||||||
@@ -321,13 +325,13 @@ class BaseTask(object):
|
|||||||
# so we can associate emitted events to Host objects
|
# so we can associate emitted events to Host objects
|
||||||
self.runner_callback.host_map = {hostname: hv.pop('remote_tower_id', '') for hostname, hv in script_data.get('_meta', {}).get('hostvars', {}).items()}
|
self.runner_callback.host_map = {hostname: hv.pop('remote_tower_id', '') for hostname, hv in script_data.get('_meta', {}).get('hostvars', {}).items()}
|
||||||
file_content = '#! /usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint(%r)\n' % json.dumps(script_data)
|
file_content = '#! /usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint(%r)\n' % json.dumps(script_data)
|
||||||
return self.write_private_data_file(private_data_dir, 'hosts', file_content, 'inventory', 0o700)
|
return self.write_private_data_file(private_data_dir, 'hosts', file_content, sub_dir='inventory', file_permissions=0o700)
|
||||||
|
|
||||||
def build_args(self, instance, private_data_dir, passwords):
|
def build_args(self, instance, private_data_dir, passwords):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def write_args_file(self, private_data_dir, args):
|
def write_args_file(self, private_data_dir, args):
|
||||||
return self.write_private_data_file(private_data_dir, 'cmdline', ansible_runner.utils.args2cmdline(*args), 'env')
|
return self.write_private_data_file(private_data_dir, 'cmdline', ansible_runner.utils.args2cmdline(*args), sub_dir='env')
|
||||||
|
|
||||||
def build_credentials_list(self, instance):
|
def build_credentials_list(self, instance):
|
||||||
return []
|
return []
|
||||||
@@ -357,12 +361,65 @@ class BaseTask(object):
|
|||||||
expect_passwords[k] = passwords.get(v, '') or ''
|
expect_passwords[k] = passwords.get(v, '') or ''
|
||||||
return expect_passwords
|
return expect_passwords
|
||||||
|
|
||||||
|
def release_lock(self, project):
|
||||||
|
try:
|
||||||
|
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
|
||||||
|
except IOError as e:
|
||||||
|
logger.error("I/O error({0}) while trying to release lock file [{1}]: {2}".format(e.errno, project.get_lock_file(), e.strerror))
|
||||||
|
os.close(self.lock_fd)
|
||||||
|
raise
|
||||||
|
|
||||||
|
os.close(self.lock_fd)
|
||||||
|
self.lock_fd = None
|
||||||
|
|
||||||
|
def acquire_lock(self, project, unified_job_id=None):
|
||||||
|
if not os.path.exists(settings.PROJECTS_ROOT):
|
||||||
|
os.mkdir(settings.PROJECTS_ROOT)
|
||||||
|
|
||||||
|
lock_path = project.get_lock_file()
|
||||||
|
if lock_path is None:
|
||||||
|
# If from migration or someone blanked local_path for any other reason, recoverable by save
|
||||||
|
project.save()
|
||||||
|
lock_path = project.get_lock_file()
|
||||||
|
if lock_path is None:
|
||||||
|
raise RuntimeError(u'Invalid lock file path')
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("I/O error({0}) while trying to open lock file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
|
||||||
|
raise
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
fcntl.lockf(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
break
|
||||||
|
except IOError as e:
|
||||||
|
if e.errno not in (errno.EAGAIN, errno.EACCES):
|
||||||
|
os.close(self.lock_fd)
|
||||||
|
logger.error("I/O error({0}) while trying to aquire lock on file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
time.sleep(1.0)
|
||||||
|
self.instance.refresh_from_db(fields=['cancel_flag'])
|
||||||
|
if self.instance.cancel_flag or signal_callback():
|
||||||
|
logger.debug(f"Unified job {self.instance.id} was canceled while waiting for project file lock")
|
||||||
|
return
|
||||||
|
waiting_time = time.time() - start_time
|
||||||
|
|
||||||
|
if waiting_time > 1.0:
|
||||||
|
logger.info(f'Job {unified_job_id} waited {waiting_time} to acquire lock for local source tree for path {lock_path}.')
|
||||||
|
|
||||||
def pre_run_hook(self, instance, private_data_dir):
|
def pre_run_hook(self, instance, private_data_dir):
|
||||||
"""
|
"""
|
||||||
Hook for any steps to run before the job/task starts
|
Hook for any steps to run before the job/task starts
|
||||||
"""
|
"""
|
||||||
instance.log_lifecycle("pre_run")
|
instance.log_lifecycle("pre_run")
|
||||||
|
|
||||||
|
# Before task is started, ensure that job_event partitions exist
|
||||||
|
create_partition(instance.event_class._meta.db_table, start=instance.created)
|
||||||
|
|
||||||
def post_run_hook(self, instance, status):
|
def post_run_hook(self, instance, status):
|
||||||
"""
|
"""
|
||||||
Hook for any steps to run before job/task is marked as complete.
|
Hook for any steps to run before job/task is marked as complete.
|
||||||
@@ -375,15 +432,9 @@ class BaseTask(object):
|
|||||||
"""
|
"""
|
||||||
instance.log_lifecycle("finalize_run")
|
instance.log_lifecycle("finalize_run")
|
||||||
artifact_dir = os.path.join(private_data_dir, 'artifacts', str(self.instance.id))
|
artifact_dir = os.path.join(private_data_dir, 'artifacts', str(self.instance.id))
|
||||||
job_profiling_dir = os.path.join(artifact_dir, 'playbook_profiling')
|
|
||||||
awx_profiling_dir = '/var/log/tower/playbook_profiling/'
|
|
||||||
collections_info = os.path.join(artifact_dir, 'collections.json')
|
collections_info = os.path.join(artifact_dir, 'collections.json')
|
||||||
ansible_version_file = os.path.join(artifact_dir, 'ansible_version.txt')
|
ansible_version_file = os.path.join(artifact_dir, 'ansible_version.txt')
|
||||||
|
|
||||||
if not os.path.exists(awx_profiling_dir):
|
|
||||||
os.mkdir(awx_profiling_dir)
|
|
||||||
if os.path.isdir(job_profiling_dir):
|
|
||||||
shutil.copytree(job_profiling_dir, os.path.join(awx_profiling_dir, str(instance.pk)))
|
|
||||||
if os.path.exists(collections_info):
|
if os.path.exists(collections_info):
|
||||||
with open(collections_info) as ee_json_info:
|
with open(collections_info) as ee_json_info:
|
||||||
ee_collections_info = json.loads(ee_json_info.read())
|
ee_collections_info = json.loads(ee_json_info.read())
|
||||||
@@ -396,11 +447,17 @@ class BaseTask(object):
|
|||||||
instance.save(update_fields=['ansible_version'])
|
instance.save(update_fields=['ansible_version'])
|
||||||
|
|
||||||
@with_path_cleanup
|
@with_path_cleanup
|
||||||
|
@with_signal_handling
|
||||||
def run(self, pk, **kwargs):
|
def run(self, pk, **kwargs):
|
||||||
"""
|
"""
|
||||||
Run the job/task and capture its output.
|
Run the job/task and capture its output.
|
||||||
"""
|
"""
|
||||||
self.instance = self.model.objects.get(pk=pk)
|
self.instance = self.model.objects.get(pk=pk)
|
||||||
|
if self.instance.status != 'canceled' and self.instance.cancel_flag:
|
||||||
|
self.instance = self.update_model(self.instance.pk, start_args='', status='canceled')
|
||||||
|
if self.instance.status not in ACTIVE_STATES:
|
||||||
|
# Prevent starting the job if it has been reaped or handled by another process.
|
||||||
|
raise RuntimeError(f'Not starting {self.instance.status} task pk={pk} because {self.instance.status} is not a valid active state')
|
||||||
|
|
||||||
if self.instance.execution_environment_id is None:
|
if self.instance.execution_environment_id is None:
|
||||||
from awx.main.signals import disable_activity_stream
|
from awx.main.signals import disable_activity_stream
|
||||||
@@ -412,7 +469,6 @@ class BaseTask(object):
|
|||||||
self.instance = self.update_model(pk, status='running', start_args='') # blank field to remove encrypted passwords
|
self.instance = self.update_model(pk, status='running', start_args='') # blank field to remove encrypted passwords
|
||||||
self.instance.websocket_emit_status("running")
|
self.instance.websocket_emit_status("running")
|
||||||
status, rc = 'error', None
|
status, rc = 'error', None
|
||||||
extra_update_fields = {}
|
|
||||||
fact_modification_times = {}
|
fact_modification_times = {}
|
||||||
self.runner_callback.event_ct = 0
|
self.runner_callback.event_ct = 0
|
||||||
|
|
||||||
@@ -427,8 +483,9 @@ class BaseTask(object):
|
|||||||
self.instance.send_notification_templates("running")
|
self.instance.send_notification_templates("running")
|
||||||
private_data_dir = self.build_private_data_dir(self.instance)
|
private_data_dir = self.build_private_data_dir(self.instance)
|
||||||
self.pre_run_hook(self.instance, private_data_dir)
|
self.pre_run_hook(self.instance, private_data_dir)
|
||||||
|
self.build_project_dir(self.instance, private_data_dir)
|
||||||
self.instance.log_lifecycle("preparing_playbook")
|
self.instance.log_lifecycle("preparing_playbook")
|
||||||
if self.instance.cancel_flag:
|
if self.instance.cancel_flag or signal_callback():
|
||||||
self.instance = self.update_model(self.instance.pk, status='canceled')
|
self.instance = self.update_model(self.instance.pk, status='canceled')
|
||||||
if self.instance.status != 'running':
|
if self.instance.status != 'running':
|
||||||
# Stop the task chain and prevent starting the job if it has
|
# Stop the task chain and prevent starting the job if it has
|
||||||
@@ -523,7 +580,7 @@ class BaseTask(object):
|
|||||||
runner_settings['idle_timeout'] = idle_timeout
|
runner_settings['idle_timeout'] = idle_timeout
|
||||||
|
|
||||||
# Write out our own settings file
|
# Write out our own settings file
|
||||||
self.write_private_data_file(private_data_dir, 'settings', json.dumps(runner_settings), 'env')
|
self.write_private_data_file(private_data_dir, 'settings', json.dumps(runner_settings), sub_dir='env')
|
||||||
|
|
||||||
self.instance.log_lifecycle("running_playbook")
|
self.instance.log_lifecycle("running_playbook")
|
||||||
if isinstance(self.instance, SystemJob):
|
if isinstance(self.instance, SystemJob):
|
||||||
@@ -547,20 +604,23 @@ class BaseTask(object):
|
|||||||
rc = res.rc
|
rc = res.rc
|
||||||
|
|
||||||
if status in ('timeout', 'error'):
|
if status in ('timeout', 'error'):
|
||||||
job_explanation = f"Job terminated due to {status}"
|
self.runner_callback.delay_update(skip_if_already_set=True, job_explanation=f"Job terminated due to {status}")
|
||||||
self.instance.job_explanation = self.instance.job_explanation or job_explanation
|
|
||||||
if status == 'timeout':
|
if status == 'timeout':
|
||||||
status = 'failed'
|
status = 'failed'
|
||||||
|
elif status == 'canceled':
|
||||||
extra_update_fields['job_explanation'] = self.instance.job_explanation
|
self.instance = self.update_model(pk)
|
||||||
# ensure failure notification sends even if playbook_on_stats event is not triggered
|
cancel_flag_value = getattr(self.instance, 'cancel_flag', False)
|
||||||
handle_success_and_failure_notifications.apply_async([self.instance.id])
|
if (cancel_flag_value is False) and signal_callback():
|
||||||
|
self.runner_callback.delay_update(skip_if_already_set=True, job_explanation="Task was canceled due to receiving a shutdown signal.")
|
||||||
|
status = 'failed'
|
||||||
|
elif cancel_flag_value is False:
|
||||||
|
self.runner_callback.delay_update(skip_if_already_set=True, job_explanation="The running ansible process received a shutdown signal.")
|
||||||
|
status = 'failed'
|
||||||
except ReceptorNodeNotFound as exc:
|
except ReceptorNodeNotFound as exc:
|
||||||
extra_update_fields['job_explanation'] = str(exc)
|
self.runner_callback.delay_update(job_explanation=str(exc))
|
||||||
except Exception:
|
except Exception:
|
||||||
# this could catch programming or file system errors
|
# this could catch programming or file system errors
|
||||||
extra_update_fields['result_traceback'] = traceback.format_exc()
|
self.runner_callback.delay_update(result_traceback=traceback.format_exc())
|
||||||
logger.exception('%s Exception occurred while running task', self.instance.log_format)
|
logger.exception('%s Exception occurred while running task', self.instance.log_format)
|
||||||
finally:
|
finally:
|
||||||
logger.debug('%s finished running, producing %s events.', self.instance.log_format, self.runner_callback.event_ct)
|
logger.debug('%s finished running, producing %s events.', self.instance.log_format, self.runner_callback.event_ct)
|
||||||
@@ -570,18 +630,19 @@ class BaseTask(object):
|
|||||||
except PostRunError as exc:
|
except PostRunError as exc:
|
||||||
if status == 'successful':
|
if status == 'successful':
|
||||||
status = exc.status
|
status = exc.status
|
||||||
extra_update_fields['job_explanation'] = exc.args[0]
|
self.runner_callback.delay_update(job_explanation=exc.args[0])
|
||||||
if exc.tb:
|
if exc.tb:
|
||||||
extra_update_fields['result_traceback'] = exc.tb
|
self.runner_callback.delay_update(result_traceback=exc.tb)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('{} Post run hook errored.'.format(self.instance.log_format))
|
logger.exception('{} Post run hook errored.'.format(self.instance.log_format))
|
||||||
|
|
||||||
# We really shouldn't get into this one but just in case....
|
|
||||||
if 'got an unexpected keyword argument' in extra_update_fields.get('result_traceback', ''):
|
|
||||||
extra_update_fields['result_traceback'] = "{}\n\n{}".format(extra_update_fields['result_traceback'], ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE)
|
|
||||||
|
|
||||||
self.instance = self.update_model(pk)
|
self.instance = self.update_model(pk)
|
||||||
self.instance = self.update_model(pk, status=status, emitted_events=self.runner_callback.event_ct, **extra_update_fields)
|
self.instance = self.update_model(pk, status=status, select_for_update=True, **self.runner_callback.get_delayed_update_fields())
|
||||||
|
|
||||||
|
# Field host_status_counts is used as a metric to check if event processing is finished
|
||||||
|
# we send notifications if it is, if not, callback receiver will send them
|
||||||
|
if (self.instance.host_status_counts is not None) or (not self.runner_callback.wrapup_event_dispatched):
|
||||||
|
self.instance.send_notification_templates('succeeded' if status == 'successful' else 'failed')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.final_run_hook(self.instance, status, private_data_dir, fact_modification_times)
|
self.final_run_hook(self.instance, status, private_data_dir, fact_modification_times)
|
||||||
@@ -596,8 +657,143 @@ class BaseTask(object):
|
|||||||
raise AwxTaskError.TaskError(self.instance, rc)
|
raise AwxTaskError.TaskError(self.instance, rc)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceControlMixin(BaseTask):
|
||||||
|
"""Utility methods for tasks that run use content from source control"""
|
||||||
|
|
||||||
|
def get_sync_needs(self, project, scm_branch=None):
|
||||||
|
project_path = project.get_project_path(check_if_exists=False)
|
||||||
|
job_revision = project.scm_revision
|
||||||
|
sync_needs = []
|
||||||
|
source_update_tag = 'update_{}'.format(project.scm_type)
|
||||||
|
branch_override = bool(scm_branch and scm_branch != project.scm_branch)
|
||||||
|
# TODO: skip syncs for inventory updates. Now, UI needs a link added so clients can link to project
|
||||||
|
# source_project is only a field on inventory sources.
|
||||||
|
if isinstance(self.instance, InventoryUpdate):
|
||||||
|
sync_needs.append(source_update_tag)
|
||||||
|
elif not project.scm_type:
|
||||||
|
pass # manual projects are not synced, user has responsibility for that
|
||||||
|
elif not os.path.exists(project_path):
|
||||||
|
logger.debug(f'Performing fresh clone of {project.id} for unified job {self.instance.id} on this instance.')
|
||||||
|
sync_needs.append(source_update_tag)
|
||||||
|
elif project.scm_type == 'git' and project.scm_revision and (not branch_override):
|
||||||
|
try:
|
||||||
|
git_repo = git.Repo(project_path)
|
||||||
|
|
||||||
|
if job_revision == git_repo.head.commit.hexsha:
|
||||||
|
logger.debug(f'Skipping project sync for {self.instance.id} because commit is locally available')
|
||||||
|
else:
|
||||||
|
sync_needs.append(source_update_tag)
|
||||||
|
except (ValueError, BadGitName, git.exc.InvalidGitRepositoryError):
|
||||||
|
logger.debug(f'Needed commit for {self.instance.id} not in local source tree, will sync with remote')
|
||||||
|
sync_needs.append(source_update_tag)
|
||||||
|
else:
|
||||||
|
logger.debug(f'Project not available locally, {self.instance.id} will sync with remote')
|
||||||
|
sync_needs.append(source_update_tag)
|
||||||
|
|
||||||
|
has_cache = os.path.exists(os.path.join(project.get_cache_path(), project.cache_id))
|
||||||
|
# Galaxy requirements are not supported for manual projects
|
||||||
|
if project.scm_type and ((not has_cache) or branch_override):
|
||||||
|
sync_needs.extend(['install_roles', 'install_collections'])
|
||||||
|
|
||||||
|
return sync_needs
|
||||||
|
|
||||||
|
def spawn_project_sync(self, project, sync_needs, scm_branch=None):
|
||||||
|
pu_ig = self.instance.instance_group
|
||||||
|
pu_en = Instance.objects.me().hostname
|
||||||
|
|
||||||
|
sync_metafields = dict(
|
||||||
|
launch_type="sync",
|
||||||
|
job_type='run',
|
||||||
|
job_tags=','.join(sync_needs),
|
||||||
|
status='running',
|
||||||
|
instance_group=pu_ig,
|
||||||
|
execution_node=pu_en,
|
||||||
|
controller_node=pu_en,
|
||||||
|
celery_task_id=self.instance.celery_task_id,
|
||||||
|
)
|
||||||
|
if scm_branch and scm_branch != project.scm_branch:
|
||||||
|
sync_metafields['scm_branch'] = scm_branch
|
||||||
|
sync_metafields['scm_clean'] = True # to accomidate force pushes
|
||||||
|
if 'update_' not in sync_metafields['job_tags']:
|
||||||
|
sync_metafields['scm_revision'] = project.scm_revision
|
||||||
|
local_project_sync = project.create_project_update(_eager_fields=sync_metafields)
|
||||||
|
local_project_sync.log_lifecycle("controller_node_chosen")
|
||||||
|
local_project_sync.log_lifecycle("execution_node_chosen")
|
||||||
|
return local_project_sync
|
||||||
|
|
||||||
|
def sync_and_copy_without_lock(self, project, private_data_dir, scm_branch=None):
|
||||||
|
sync_needs = self.get_sync_needs(project, scm_branch=scm_branch)
|
||||||
|
|
||||||
|
if sync_needs:
|
||||||
|
local_project_sync = self.spawn_project_sync(project, sync_needs, scm_branch=scm_branch)
|
||||||
|
# save the associated job before calling run() so that a
|
||||||
|
# cancel() call on the job can cancel the project update
|
||||||
|
if isinstance(self.instance, Job):
|
||||||
|
self.instance = self.update_model(self.instance.pk, project_update=local_project_sync)
|
||||||
|
else:
|
||||||
|
self.instance = self.update_model(self.instance.pk, source_project_update=local_project_sync)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# the job private_data_dir is passed so sync can download roles and collections there
|
||||||
|
sync_task = RunProjectUpdate(job_private_data_dir=private_data_dir)
|
||||||
|
sync_task.run(local_project_sync.id)
|
||||||
|
local_project_sync.refresh_from_db()
|
||||||
|
if isinstance(self.instance, Job):
|
||||||
|
self.instance = self.update_model(self.instance.pk, scm_revision=local_project_sync.scm_revision)
|
||||||
|
except Exception:
|
||||||
|
local_project_sync.refresh_from_db()
|
||||||
|
if local_project_sync.status != 'canceled':
|
||||||
|
self.instance = self.update_model(
|
||||||
|
self.instance.pk,
|
||||||
|
status='failed',
|
||||||
|
job_explanation=(
|
||||||
|
'Previous Task Failed: {"job_type": "project_update", '
|
||||||
|
f'"job_name": "{local_project_sync.name}", "job_id": "{local_project_sync.id}"}}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
self.instance.refresh_from_db()
|
||||||
|
if self.instance.cancel_flag:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Case where a local sync is not needed, meaning that local tree is
|
||||||
|
# up-to-date with project, job is running project current version
|
||||||
|
if isinstance(self.instance, Job):
|
||||||
|
self.instance = self.update_model(self.instance.pk, scm_revision=project.scm_revision)
|
||||||
|
# Project update does not copy the folder, so copy here
|
||||||
|
RunProjectUpdate.make_local_copy(project, private_data_dir)
|
||||||
|
|
||||||
|
def sync_and_copy(self, project, private_data_dir, scm_branch=None):
|
||||||
|
self.acquire_lock(project, self.instance.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
original_branch = None
|
||||||
|
project_path = project.get_project_path(check_if_exists=False)
|
||||||
|
if project.scm_type == 'git' and (scm_branch and scm_branch != project.scm_branch):
|
||||||
|
if os.path.exists(project_path):
|
||||||
|
git_repo = git.Repo(project_path)
|
||||||
|
if git_repo.head.is_detached:
|
||||||
|
original_branch = git_repo.head.commit
|
||||||
|
else:
|
||||||
|
original_branch = git_repo.active_branch
|
||||||
|
|
||||||
|
return self.sync_and_copy_without_lock(project, private_data_dir, scm_branch=scm_branch)
|
||||||
|
finally:
|
||||||
|
# We have made the copy so we can set the tree back to its normal state
|
||||||
|
if original_branch:
|
||||||
|
# for git project syncs, non-default branches can be problems
|
||||||
|
# restore to branch the repo was on before this run
|
||||||
|
try:
|
||||||
|
original_branch.checkout()
|
||||||
|
except Exception:
|
||||||
|
# this could have failed due to dirty tree, but difficult to predict all cases
|
||||||
|
logger.exception(f'Failed to restore project repo to prior state after {self.instance.id}')
|
||||||
|
|
||||||
|
self.release_lock(project)
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
@task(queue=get_local_queuename)
|
||||||
class RunJob(BaseTask):
|
class RunJob(SourceControlMixin, BaseTask):
|
||||||
"""
|
"""
|
||||||
Run a job using ansible-playbook.
|
Run a job using ansible-playbook.
|
||||||
"""
|
"""
|
||||||
@@ -866,98 +1062,14 @@ class RunJob(BaseTask):
|
|||||||
job = self.update_model(job.pk, status='failed', job_explanation=msg)
|
job = self.update_model(job.pk, status='failed', job_explanation=msg)
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
project_path = job.project.get_project_path(check_if_exists=False)
|
|
||||||
job_revision = job.project.scm_revision
|
|
||||||
sync_needs = []
|
|
||||||
source_update_tag = 'update_{}'.format(job.project.scm_type)
|
|
||||||
branch_override = bool(job.scm_branch and job.scm_branch != job.project.scm_branch)
|
|
||||||
if not job.project.scm_type:
|
|
||||||
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.append(source_update_tag)
|
|
||||||
elif job.project.scm_type == 'git' and job.project.scm_revision and (not branch_override):
|
|
||||||
try:
|
|
||||||
git_repo = git.Repo(project_path)
|
|
||||||
|
|
||||||
if job_revision == git_repo.head.commit.hexsha:
|
|
||||||
logger.debug('Skipping project sync for {} because commit is locally available'.format(job.log_format))
|
|
||||||
else:
|
|
||||||
sync_needs.append(source_update_tag)
|
|
||||||
except (ValueError, BadGitName, git.exc.InvalidGitRepositoryError):
|
|
||||||
logger.debug('Needed commit for {} not in local source tree, will sync with remote'.format(job.log_format))
|
|
||||||
sync_needs.append(source_update_tag)
|
|
||||||
else:
|
|
||||||
logger.debug('Project not available locally, {} will sync with remote'.format(job.log_format))
|
|
||||||
sync_needs.append(source_update_tag)
|
|
||||||
|
|
||||||
has_cache = os.path.exists(os.path.join(job.project.get_cache_path(), job.project.cache_id))
|
|
||||||
# Galaxy requirements are not supported for manual projects
|
|
||||||
if job.project.scm_type and ((not has_cache) or branch_override):
|
|
||||||
sync_needs.extend(['install_roles', 'install_collections'])
|
|
||||||
|
|
||||||
if sync_needs:
|
|
||||||
pu_ig = job.instance_group
|
|
||||||
pu_en = Instance.objects.me().hostname
|
|
||||||
|
|
||||||
sync_metafields = dict(
|
|
||||||
launch_type="sync",
|
|
||||||
job_type='run',
|
|
||||||
job_tags=','.join(sync_needs),
|
|
||||||
status='running',
|
|
||||||
instance_group=pu_ig,
|
|
||||||
execution_node=pu_en,
|
|
||||||
controller_node=pu_en,
|
|
||||||
celery_task_id=job.celery_task_id,
|
|
||||||
)
|
|
||||||
if branch_override:
|
|
||||||
sync_metafields['scm_branch'] = job.scm_branch
|
|
||||||
sync_metafields['scm_clean'] = True # to accomidate force pushes
|
|
||||||
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)
|
|
||||||
local_project_sync.log_lifecycle("controller_node_chosen")
|
|
||||||
local_project_sync.log_lifecycle("execution_node_chosen")
|
|
||||||
create_partition(local_project_sync.event_class._meta.db_table, start=local_project_sync.created)
|
|
||||||
# save the associated job before calling run() so that a
|
|
||||||
# cancel() call on the job can cancel the project update
|
|
||||||
job = self.update_model(job.pk, project_update=local_project_sync)
|
|
||||||
|
|
||||||
project_update_task = local_project_sync._get_task_class()
|
|
||||||
try:
|
|
||||||
# the job private_data_dir is passed so sync can download roles and collections there
|
|
||||||
sync_task = project_update_task(job_private_data_dir=private_data_dir)
|
|
||||||
sync_task.run(local_project_sync.id)
|
|
||||||
local_project_sync.refresh_from_db()
|
|
||||||
job = self.update_model(job.pk, scm_revision=local_project_sync.scm_revision)
|
|
||||||
except Exception:
|
|
||||||
local_project_sync.refresh_from_db()
|
|
||||||
if local_project_sync.status != 'canceled':
|
|
||||||
job = self.update_model(
|
|
||||||
job.pk,
|
|
||||||
status='failed',
|
|
||||||
job_explanation=(
|
|
||||||
'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}'
|
|
||||||
% ('project_update', local_project_sync.name, local_project_sync.id)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
job.refresh_from_db()
|
|
||||||
if job.cancel_flag:
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# Case where a local sync is not needed, meaning that local tree is
|
|
||||||
# up-to-date with project, job is running project current version
|
|
||||||
if job_revision:
|
|
||||||
job = self.update_model(job.pk, scm_revision=job_revision)
|
|
||||||
# Project update does not copy the folder, so copy here
|
|
||||||
RunProjectUpdate.make_local_copy(job.project, private_data_dir, scm_revision=job_revision)
|
|
||||||
|
|
||||||
if job.inventory.kind == 'smart':
|
if job.inventory.kind == 'smart':
|
||||||
# cache smart inventory memberships so that the host_filter query is not
|
# cache smart inventory memberships so that the host_filter query is not
|
||||||
# ran inside of the event saving code
|
# ran inside of the event saving code
|
||||||
update_smart_memberships_for_inventory(job.inventory)
|
update_smart_memberships_for_inventory(job.inventory)
|
||||||
|
|
||||||
|
def build_project_dir(self, job, private_data_dir):
|
||||||
|
self.sync_and_copy(job.project, private_data_dir, scm_branch=job.scm_branch)
|
||||||
|
|
||||||
def final_run_hook(self, job, status, private_data_dir, fact_modification_times):
|
def final_run_hook(self, job, status, private_data_dir, fact_modification_times):
|
||||||
super(RunJob, self).final_run_hook(job, status, private_data_dir, fact_modification_times)
|
super(RunJob, self).final_run_hook(job, status, private_data_dir, fact_modification_times)
|
||||||
if not private_data_dir:
|
if not private_data_dir:
|
||||||
@@ -989,7 +1101,6 @@ class RunProjectUpdate(BaseTask):
|
|||||||
|
|
||||||
def __init__(self, *args, job_private_data_dir=None, **kwargs):
|
def __init__(self, *args, job_private_data_dir=None, **kwargs):
|
||||||
super(RunProjectUpdate, self).__init__(*args, **kwargs)
|
super(RunProjectUpdate, self).__init__(*args, **kwargs)
|
||||||
self.original_branch = None
|
|
||||||
self.job_private_data_dir = job_private_data_dir
|
self.job_private_data_dir = job_private_data_dir
|
||||||
|
|
||||||
def build_private_data(self, project_update, private_data_dir):
|
def build_private_data(self, project_update, private_data_dir):
|
||||||
@@ -1176,132 +1287,13 @@ class RunProjectUpdate(BaseTask):
|
|||||||
d[r'^Are you sure you want to continue connecting \(yes/no\)\?\s*?$'] = 'yes'
|
d[r'^Are you sure you want to continue connecting \(yes/no\)\?\s*?$'] = 'yes'
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def _update_dependent_inventories(self, project_update, dependent_inventory_sources):
|
|
||||||
scm_revision = project_update.project.scm_revision
|
|
||||||
inv_update_class = InventoryUpdate._get_task_class()
|
|
||||||
for inv_src in dependent_inventory_sources:
|
|
||||||
if not inv_src.update_on_project_update:
|
|
||||||
continue
|
|
||||||
if inv_src.scm_last_revision == scm_revision:
|
|
||||||
logger.debug('Skipping SCM inventory update for `{}` because ' 'project has not changed.'.format(inv_src.name))
|
|
||||||
continue
|
|
||||||
logger.debug('Local dependent inventory update for `{}`.'.format(inv_src.name))
|
|
||||||
with transaction.atomic():
|
|
||||||
if InventoryUpdate.objects.filter(inventory_source=inv_src, status__in=ACTIVE_STATES).exists():
|
|
||||||
logger.debug('Skipping SCM inventory update for `{}` because ' 'another update is already active.'.format(inv_src.name))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if settings.IS_K8S:
|
|
||||||
instance_group = InventoryUpdate(inventory_source=inv_src).preferred_instance_groups[0]
|
|
||||||
else:
|
|
||||||
instance_group = project_update.instance_group
|
|
||||||
|
|
||||||
local_inv_update = inv_src.create_inventory_update(
|
|
||||||
_eager_fields=dict(
|
|
||||||
launch_type='scm',
|
|
||||||
status='running',
|
|
||||||
instance_group=instance_group,
|
|
||||||
execution_node=project_update.execution_node,
|
|
||||||
controller_node=project_update.execution_node,
|
|
||||||
source_project_update=project_update,
|
|
||||||
celery_task_id=project_update.celery_task_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
local_inv_update.log_lifecycle("controller_node_chosen")
|
|
||||||
local_inv_update.log_lifecycle("execution_node_chosen")
|
|
||||||
try:
|
|
||||||
create_partition(local_inv_update.event_class._meta.db_table, start=local_inv_update.created)
|
|
||||||
inv_update_class().run(local_inv_update.id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception('{} Unhandled exception updating dependent SCM inventory sources.'.format(project_update.log_format))
|
|
||||||
|
|
||||||
try:
|
|
||||||
project_update.refresh_from_db()
|
|
||||||
except ProjectUpdate.DoesNotExist:
|
|
||||||
logger.warning('Project update deleted during updates of dependent SCM inventory sources.')
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
local_inv_update.refresh_from_db()
|
|
||||||
except InventoryUpdate.DoesNotExist:
|
|
||||||
logger.warning('%s Dependent inventory update deleted during execution.', project_update.log_format)
|
|
||||||
continue
|
|
||||||
if project_update.cancel_flag:
|
|
||||||
logger.info('Project update {} was canceled while updating dependent inventories.'.format(project_update.log_format))
|
|
||||||
break
|
|
||||||
if local_inv_update.cancel_flag:
|
|
||||||
logger.info('Continuing to process project dependencies after {} was canceled'.format(local_inv_update.log_format))
|
|
||||||
if local_inv_update.status == 'successful':
|
|
||||||
inv_src.scm_last_revision = scm_revision
|
|
||||||
inv_src.save(update_fields=['scm_last_revision'])
|
|
||||||
|
|
||||||
def release_lock(self, instance):
|
|
||||||
try:
|
|
||||||
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
|
|
||||||
except IOError as e:
|
|
||||||
logger.error("I/O error({0}) while trying to release lock file [{1}]: {2}".format(e.errno, instance.get_lock_file(), e.strerror))
|
|
||||||
os.close(self.lock_fd)
|
|
||||||
raise
|
|
||||||
|
|
||||||
os.close(self.lock_fd)
|
|
||||||
self.lock_fd = None
|
|
||||||
|
|
||||||
'''
|
|
||||||
Note: We don't support blocking=False
|
|
||||||
'''
|
|
||||||
|
|
||||||
def acquire_lock(self, instance, blocking=True):
|
|
||||||
lock_path = instance.get_lock_file()
|
|
||||||
if lock_path is None:
|
|
||||||
# If from migration or someone blanked local_path for any other reason, recoverable by save
|
|
||||||
instance.save()
|
|
||||||
lock_path = instance.get_lock_file()
|
|
||||||
if lock_path is None:
|
|
||||||
raise RuntimeError(u'Invalid lock file path')
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT)
|
|
||||||
except OSError as e:
|
|
||||||
logger.error("I/O error({0}) while trying to open lock file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
|
|
||||||
raise
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
instance.refresh_from_db(fields=['cancel_flag'])
|
|
||||||
if instance.cancel_flag:
|
|
||||||
logger.debug("ProjectUpdate({0}) was canceled".format(instance.pk))
|
|
||||||
return
|
|
||||||
fcntl.lockf(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
break
|
|
||||||
except IOError as e:
|
|
||||||
if e.errno not in (errno.EAGAIN, errno.EACCES):
|
|
||||||
os.close(self.lock_fd)
|
|
||||||
logger.error("I/O error({0}) while trying to aquire lock on file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
time.sleep(1.0)
|
|
||||||
waiting_time = time.time() - start_time
|
|
||||||
|
|
||||||
if waiting_time > 1.0:
|
|
||||||
logger.info('{} spent {} waiting to acquire lock for local source tree ' 'for path {}.'.format(instance.log_format, waiting_time, lock_path))
|
|
||||||
|
|
||||||
def pre_run_hook(self, instance, private_data_dir):
|
def pre_run_hook(self, instance, private_data_dir):
|
||||||
super(RunProjectUpdate, self).pre_run_hook(instance, private_data_dir)
|
super(RunProjectUpdate, self).pre_run_hook(instance, private_data_dir)
|
||||||
# re-create root project folder if a natural disaster has destroyed it
|
# re-create root project folder if a natural disaster has destroyed it
|
||||||
if not os.path.exists(settings.PROJECTS_ROOT):
|
|
||||||
os.mkdir(settings.PROJECTS_ROOT)
|
|
||||||
project_path = instance.project.get_project_path(check_if_exists=False)
|
project_path = instance.project.get_project_path(check_if_exists=False)
|
||||||
|
|
||||||
self.acquire_lock(instance)
|
if instance.launch_type != 'sync':
|
||||||
|
self.acquire_lock(instance.project, instance.id)
|
||||||
self.original_branch = None
|
|
||||||
if instance.scm_type == 'git' and instance.branch_override:
|
|
||||||
if os.path.exists(project_path):
|
|
||||||
git_repo = git.Repo(project_path)
|
|
||||||
if git_repo.head.is_detached:
|
|
||||||
self.original_branch = git_repo.head.commit
|
|
||||||
else:
|
|
||||||
self.original_branch = git_repo.active_branch
|
|
||||||
|
|
||||||
if not os.path.exists(project_path):
|
if not os.path.exists(project_path):
|
||||||
os.makedirs(project_path) # used as container mount
|
os.makedirs(project_path) # used as container mount
|
||||||
@@ -1312,11 +1304,12 @@ class RunProjectUpdate(BaseTask):
|
|||||||
shutil.rmtree(stage_path)
|
shutil.rmtree(stage_path)
|
||||||
os.makedirs(stage_path) # presence of empty cache indicates lack of roles or collections
|
os.makedirs(stage_path) # presence of empty cache indicates lack of roles or collections
|
||||||
|
|
||||||
|
def build_project_dir(self, instance, private_data_dir):
|
||||||
# the project update playbook is not in a git repo, but uses a vendoring directory
|
# the project update playbook is not in a git repo, but uses a vendoring directory
|
||||||
# to be consistent with the ansible-runner model,
|
# to be consistent with the ansible-runner model,
|
||||||
# that is moved into the runner project folder here
|
# that is moved into the runner project folder here
|
||||||
awx_playbooks = self.get_path_to('../../', 'playbooks')
|
awx_playbooks = self.get_path_to('../../', 'playbooks')
|
||||||
copy_tree(awx_playbooks, os.path.join(private_data_dir, 'project'))
|
shutil.copytree(awx_playbooks, os.path.join(private_data_dir, 'project'))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clear_project_cache(cache_dir, keep_value):
|
def clear_project_cache(cache_dir, keep_value):
|
||||||
@@ -1333,50 +1326,18 @@ class RunProjectUpdate(BaseTask):
|
|||||||
logger.warning(f"Could not remove cache directory {old_path}")
|
logger.warning(f"Could not remove cache directory {old_path}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def make_local_copy(p, job_private_data_dir, scm_revision=None):
|
def make_local_copy(project, job_private_data_dir):
|
||||||
"""Copy project content (roles and collections) to a job private_data_dir
|
"""Copy project content (roles and collections) to a job private_data_dir
|
||||||
|
|
||||||
:param object p: Either a project or a project update
|
:param object project: Either a project or a project update
|
||||||
:param str job_private_data_dir: The root of the target ansible-runner folder
|
:param str job_private_data_dir: The root of the target ansible-runner folder
|
||||||
:param str scm_revision: For branch_override cases, the git revision to copy
|
|
||||||
"""
|
"""
|
||||||
project_path = p.get_project_path(check_if_exists=False)
|
project_path = project.get_project_path(check_if_exists=False)
|
||||||
destination_folder = os.path.join(job_private_data_dir, 'project')
|
destination_folder = os.path.join(job_private_data_dir, 'project')
|
||||||
if not scm_revision:
|
shutil.copytree(project_path, destination_folder, ignore=shutil.ignore_patterns('.git'), symlinks=True)
|
||||||
scm_revision = p.scm_revision
|
|
||||||
|
|
||||||
if p.scm_type == 'git':
|
|
||||||
git_repo = git.Repo(project_path)
|
|
||||||
if not os.path.exists(destination_folder):
|
|
||||||
os.mkdir(destination_folder, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
|
|
||||||
tmp_branch_name = 'awx_internal/{}'.format(uuid4())
|
|
||||||
# always clone based on specific job revision
|
|
||||||
if not p.scm_revision:
|
|
||||||
raise RuntimeError('Unexpectedly could not determine a revision to run from project.')
|
|
||||||
source_branch = git_repo.create_head(tmp_branch_name, p.scm_revision)
|
|
||||||
# git clone must take file:// syntax for source repo or else options like depth will be ignored
|
|
||||||
source_as_uri = Path(project_path).as_uri()
|
|
||||||
git.Repo.clone_from(
|
|
||||||
source_as_uri,
|
|
||||||
destination_folder,
|
|
||||||
branch=source_branch,
|
|
||||||
depth=1,
|
|
||||||
single_branch=True, # shallow, do not copy full history
|
|
||||||
)
|
|
||||||
# submodules copied in loop because shallow copies from local HEADs are ideal
|
|
||||||
# and no git clone submodule options are compatible with minimum requirements
|
|
||||||
for submodule in git_repo.submodules:
|
|
||||||
subrepo_path = os.path.abspath(os.path.join(project_path, submodule.path))
|
|
||||||
subrepo_destination_folder = os.path.abspath(os.path.join(destination_folder, submodule.path))
|
|
||||||
subrepo_uri = Path(subrepo_path).as_uri()
|
|
||||||
git.Repo.clone_from(subrepo_uri, subrepo_destination_folder, depth=1, single_branch=True)
|
|
||||||
# force option is necessary because remote refs are not counted, although no information is lost
|
|
||||||
git_repo.delete_head(tmp_branch_name, force=True)
|
|
||||||
else:
|
|
||||||
copy_tree(project_path, destination_folder, preserve_symlinks=1)
|
|
||||||
|
|
||||||
# copy over the roles and collection cache to job folder
|
# copy over the roles and collection cache to job folder
|
||||||
cache_path = os.path.join(p.get_cache_path(), p.cache_id)
|
cache_path = os.path.join(project.get_cache_path(), project.cache_id)
|
||||||
subfolders = []
|
subfolders = []
|
||||||
if settings.AWX_COLLECTIONS_ENABLED:
|
if settings.AWX_COLLECTIONS_ENABLED:
|
||||||
subfolders.append('requirements_collections')
|
subfolders.append('requirements_collections')
|
||||||
@@ -1386,8 +1347,8 @@ class RunProjectUpdate(BaseTask):
|
|||||||
cache_subpath = os.path.join(cache_path, subfolder)
|
cache_subpath = os.path.join(cache_path, subfolder)
|
||||||
if os.path.exists(cache_subpath):
|
if os.path.exists(cache_subpath):
|
||||||
dest_subpath = os.path.join(job_private_data_dir, subfolder)
|
dest_subpath = os.path.join(job_private_data_dir, subfolder)
|
||||||
copy_tree(cache_subpath, dest_subpath, preserve_symlinks=1)
|
shutil.copytree(cache_subpath, dest_subpath, symlinks=True)
|
||||||
logger.debug('{0} {1} prepared {2} from cache'.format(type(p).__name__, p.pk, dest_subpath))
|
logger.debug('{0} {1} prepared {2} from cache'.format(type(project).__name__, project.pk, dest_subpath))
|
||||||
|
|
||||||
def post_run_hook(self, instance, status):
|
def post_run_hook(self, instance, status):
|
||||||
super(RunProjectUpdate, self).post_run_hook(instance, status)
|
super(RunProjectUpdate, self).post_run_hook(instance, status)
|
||||||
@@ -1417,23 +1378,13 @@ class RunProjectUpdate(BaseTask):
|
|||||||
if self.job_private_data_dir:
|
if self.job_private_data_dir:
|
||||||
if status == 'successful':
|
if status == 'successful':
|
||||||
# copy project folder before resetting to default branch
|
# copy project folder before resetting to default branch
|
||||||
# because some git-tree-specific resources (like submodules) might matter
|
|
||||||
self.make_local_copy(instance, self.job_private_data_dir)
|
self.make_local_copy(instance, self.job_private_data_dir)
|
||||||
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:
|
finally:
|
||||||
self.release_lock(instance)
|
if instance.launch_type != 'sync':
|
||||||
|
self.release_lock(instance.project)
|
||||||
|
|
||||||
p = instance.project
|
p = instance.project
|
||||||
if instance.job_type == 'check' and status not in (
|
if instance.job_type == 'check' and status not in ('failed', 'canceled'):
|
||||||
'failed',
|
|
||||||
'canceled',
|
|
||||||
):
|
|
||||||
if self.runner_callback.playbook_new_revision:
|
if self.runner_callback.playbook_new_revision:
|
||||||
p.scm_revision = self.runner_callback.playbook_new_revision
|
p.scm_revision = self.runner_callback.playbook_new_revision
|
||||||
else:
|
else:
|
||||||
@@ -1443,12 +1394,6 @@ class RunProjectUpdate(BaseTask):
|
|||||||
p.inventory_files = p.inventories
|
p.inventory_files = p.inventories
|
||||||
p.save(update_fields=['scm_revision', 'playbook_files', 'inventory_files'])
|
p.save(update_fields=['scm_revision', 'playbook_files', 'inventory_files'])
|
||||||
|
|
||||||
# Update any inventories that depend on this project
|
|
||||||
dependent_inventory_sources = p.scm_inventory_sources.filter(update_on_project_update=True)
|
|
||||||
if len(dependent_inventory_sources) > 0:
|
|
||||||
if status == 'successful' and instance.launch_type != 'sync':
|
|
||||||
self._update_dependent_inventories(instance, dependent_inventory_sources)
|
|
||||||
|
|
||||||
def build_execution_environment_params(self, instance, private_data_dir):
|
def build_execution_environment_params(self, instance, private_data_dir):
|
||||||
if settings.IS_K8S:
|
if settings.IS_K8S:
|
||||||
return {}
|
return {}
|
||||||
@@ -1459,15 +1404,15 @@ class RunProjectUpdate(BaseTask):
|
|||||||
params.setdefault('container_volume_mounts', [])
|
params.setdefault('container_volume_mounts', [])
|
||||||
params['container_volume_mounts'].extend(
|
params['container_volume_mounts'].extend(
|
||||||
[
|
[
|
||||||
f"{project_path}:{project_path}:Z",
|
f"{project_path}:{project_path}:z",
|
||||||
f"{cache_path}:{cache_path}:Z",
|
f"{cache_path}:{cache_path}:z",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
@task(queue=get_local_queuename)
|
||||||
class RunInventoryUpdate(BaseTask):
|
class RunInventoryUpdate(SourceControlMixin, BaseTask):
|
||||||
|
|
||||||
model = InventoryUpdate
|
model = InventoryUpdate
|
||||||
event_model = InventoryUpdateEvent
|
event_model = InventoryUpdateEvent
|
||||||
@@ -1609,7 +1554,7 @@ class RunInventoryUpdate(BaseTask):
|
|||||||
if injector is not None:
|
if injector is not None:
|
||||||
content = injector.inventory_contents(inventory_update, private_data_dir)
|
content = injector.inventory_contents(inventory_update, private_data_dir)
|
||||||
# must be a statically named file
|
# must be a statically named file
|
||||||
self.write_private_data_file(private_data_dir, injector.filename, content, 'inventory', 0o700)
|
self.write_private_data_file(private_data_dir, injector.filename, content, sub_dir='inventory', file_permissions=0o700)
|
||||||
rel_path = os.path.join('inventory', injector.filename)
|
rel_path = os.path.join('inventory', injector.filename)
|
||||||
elif src == 'scm':
|
elif src == 'scm':
|
||||||
rel_path = os.path.join('project', inventory_update.source_path)
|
rel_path = os.path.join('project', inventory_update.source_path)
|
||||||
@@ -1623,61 +1568,18 @@ class RunInventoryUpdate(BaseTask):
|
|||||||
# All credentials not used by inventory source injector
|
# All credentials not used by inventory source injector
|
||||||
return inventory_update.get_extra_credentials()
|
return inventory_update.get_extra_credentials()
|
||||||
|
|
||||||
def pre_run_hook(self, inventory_update, private_data_dir):
|
def build_project_dir(self, inventory_update, private_data_dir):
|
||||||
super(RunInventoryUpdate, self).pre_run_hook(inventory_update, private_data_dir)
|
|
||||||
source_project = None
|
source_project = None
|
||||||
if inventory_update.inventory_source:
|
if inventory_update.inventory_source:
|
||||||
source_project = inventory_update.inventory_source.source_project
|
source_project = inventory_update.inventory_source.source_project
|
||||||
if (
|
|
||||||
inventory_update.source == 'scm' and inventory_update.launch_type != 'scm' and source_project and source_project.scm_type
|
|
||||||
): # never ever update manual projects
|
|
||||||
|
|
||||||
# Check if the content cache exists, so that we do not unnecessarily re-download roles
|
if inventory_update.source == 'scm':
|
||||||
sync_needs = ['update_{}'.format(source_project.scm_type)]
|
if not source_project:
|
||||||
has_cache = os.path.exists(os.path.join(source_project.get_cache_path(), source_project.cache_id))
|
raise RuntimeError('Could not find project to run SCM inventory update from.')
|
||||||
# Galaxy requirements are not supported for manual projects
|
self.sync_and_copy(source_project, private_data_dir)
|
||||||
if not has_cache:
|
else:
|
||||||
sync_needs.extend(['install_roles', 'install_collections'])
|
# If source is not SCM make an empty project directory, content is built inside inventory folder
|
||||||
|
super(RunInventoryUpdate, self).build_project_dir(inventory_update, private_data_dir)
|
||||||
local_project_sync = source_project.create_project_update(
|
|
||||||
_eager_fields=dict(
|
|
||||||
launch_type="sync",
|
|
||||||
job_type='run',
|
|
||||||
job_tags=','.join(sync_needs),
|
|
||||||
status='running',
|
|
||||||
execution_node=Instance.objects.me().hostname,
|
|
||||||
controller_node=Instance.objects.me().hostname,
|
|
||||||
instance_group=inventory_update.instance_group,
|
|
||||||
celery_task_id=inventory_update.celery_task_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
local_project_sync.log_lifecycle("controller_node_chosen")
|
|
||||||
local_project_sync.log_lifecycle("execution_node_chosen")
|
|
||||||
create_partition(local_project_sync.event_class._meta.db_table, start=local_project_sync.created)
|
|
||||||
# associate the inventory update before calling run() so that a
|
|
||||||
# cancel() call on the inventory update can cancel the project update
|
|
||||||
local_project_sync.scm_inventory_updates.add(inventory_update)
|
|
||||||
|
|
||||||
project_update_task = local_project_sync._get_task_class()
|
|
||||||
try:
|
|
||||||
sync_task = project_update_task(job_private_data_dir=private_data_dir)
|
|
||||||
sync_task.run(local_project_sync.id)
|
|
||||||
local_project_sync.refresh_from_db()
|
|
||||||
inventory_update.inventory_source.scm_last_revision = local_project_sync.scm_revision
|
|
||||||
inventory_update.inventory_source.save(update_fields=['scm_last_revision'])
|
|
||||||
except Exception:
|
|
||||||
inventory_update = self.update_model(
|
|
||||||
inventory_update.pk,
|
|
||||||
status='failed',
|
|
||||||
job_explanation=(
|
|
||||||
'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}'
|
|
||||||
% ('project_update', local_project_sync.name, local_project_sync.id)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
elif inventory_update.source == 'scm' and inventory_update.launch_type == 'scm' and source_project:
|
|
||||||
# This follows update, not sync, so make copy here
|
|
||||||
RunProjectUpdate.make_local_copy(source_project, private_data_dir)
|
|
||||||
|
|
||||||
def post_run_hook(self, inventory_update, status):
|
def post_run_hook(self, inventory_update, status):
|
||||||
super(RunInventoryUpdate, self).post_run_hook(inventory_update, status)
|
super(RunInventoryUpdate, self).post_run_hook(inventory_update, status)
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ from awx.main.utils.common import (
|
|||||||
parse_yaml_or_json,
|
parse_yaml_or_json,
|
||||||
cleanup_new_process,
|
cleanup_new_process,
|
||||||
)
|
)
|
||||||
from awx.main.constants import (
|
from awx.main.constants import MAX_ISOLATED_PATH_COLON_DELIMITER
|
||||||
MAX_ISOLATED_PATH_COLON_DELIMITER,
|
|
||||||
ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Receptorctl
|
# Receptorctl
|
||||||
from receptorctl.socket_interface import ReceptorControl
|
from receptorctl.socket_interface import ReceptorControl
|
||||||
@@ -102,16 +99,22 @@ def administrative_workunit_reaper(work_list=None):
|
|||||||
|
|
||||||
for unit_id, work_data in work_list.items():
|
for unit_id, work_data in work_list.items():
|
||||||
extra_data = work_data.get('ExtraData')
|
extra_data = work_data.get('ExtraData')
|
||||||
if (extra_data is None) or (extra_data.get('RemoteWorkType') != 'ansible-runner'):
|
if extra_data is None:
|
||||||
continue # if this is not ansible-runner work, we do not want to touch it
|
continue # if this is not ansible-runner work, we do not want to touch it
|
||||||
params = extra_data.get('RemoteParams', {}).get('params')
|
if isinstance(extra_data, str):
|
||||||
if not params:
|
if not work_data.get('StateName', None) or work_data.get('StateName') in RECEPTOR_ACTIVE_STATES:
|
||||||
continue
|
continue
|
||||||
if not (params == '--worker-info' or params.startswith('cleanup')):
|
else:
|
||||||
continue # if this is not a cleanup or health check, we do not want to touch it
|
if extra_data.get('RemoteWorkType') != 'ansible-runner':
|
||||||
if work_data.get('StateName') in RECEPTOR_ACTIVE_STATES:
|
continue
|
||||||
continue # do not want to touch active work units
|
params = extra_data.get('RemoteParams', {}).get('params')
|
||||||
logger.info(f'Reaping orphaned work unit {unit_id} with params {params}')
|
if not params:
|
||||||
|
continue
|
||||||
|
if not (params == '--worker-info' or params.startswith('cleanup')):
|
||||||
|
continue # if this is not a cleanup or health check, we do not want to touch it
|
||||||
|
if work_data.get('StateName') in RECEPTOR_ACTIVE_STATES:
|
||||||
|
continue # do not want to touch active work units
|
||||||
|
logger.info(f'Reaping orphaned work unit {unit_id} with params {params}')
|
||||||
receptor_ctl.simple_command(f"work release {unit_id}")
|
receptor_ctl.simple_command(f"work release {unit_id}")
|
||||||
|
|
||||||
|
|
||||||
@@ -350,6 +353,11 @@ class AWXReceptorJob:
|
|||||||
resultsock.shutdown(socket.SHUT_RDWR)
|
resultsock.shutdown(socket.SHUT_RDWR)
|
||||||
resultfile.close()
|
resultfile.close()
|
||||||
elif res.status == 'error':
|
elif res.status == 'error':
|
||||||
|
# If ansible-runner ran, but an error occured at runtime, the traceback information
|
||||||
|
# is saved via the status_handler passed in to the processor.
|
||||||
|
if 'result_traceback' in self.task.runner_callback.extra_update_fields:
|
||||||
|
return res
|
||||||
|
|
||||||
try:
|
try:
|
||||||
unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}')
|
unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}')
|
||||||
detail = unit_status.get('Detail', None)
|
detail = unit_status.get('Detail', None)
|
||||||
@@ -365,28 +373,19 @@ class AWXReceptorJob:
|
|||||||
logger.warning(f"Could not launch pod for {log_name}. Exceeded quota.")
|
logger.warning(f"Could not launch pod for {log_name}. Exceeded quota.")
|
||||||
self.task.update_model(self.task.instance.pk, status='pending')
|
self.task.update_model(self.task.instance.pk, status='pending')
|
||||||
return
|
return
|
||||||
# If ansible-runner ran, but an error occured at runtime, the traceback information
|
|
||||||
# is saved via the status_handler passed in to the processor.
|
|
||||||
if state_name == 'Succeeded':
|
|
||||||
return res
|
|
||||||
|
|
||||||
if not self.task.instance.result_traceback:
|
try:
|
||||||
try:
|
resultsock = receptor_ctl.get_work_results(self.unit_id, return_sockfile=True)
|
||||||
resultsock = receptor_ctl.get_work_results(self.unit_id, return_sockfile=True)
|
lines = resultsock.readlines()
|
||||||
lines = resultsock.readlines()
|
receptor_output = b"".join(lines).decode()
|
||||||
receptor_output = b"".join(lines).decode()
|
if receptor_output:
|
||||||
if receptor_output:
|
self.task.runner_callback.delay_update(result_traceback=receptor_output)
|
||||||
self.task.instance.result_traceback = receptor_output
|
elif detail:
|
||||||
if 'got an unexpected keyword argument' in receptor_output:
|
self.task.runner_callback.delay_update(result_traceback=detail)
|
||||||
self.task.instance.result_traceback = "{}\n\n{}".format(receptor_output, ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE)
|
else:
|
||||||
self.task.instance.save(update_fields=['result_traceback'])
|
logger.warning(f'No result details or output from {self.task.instance.log_format}, status:\n{state_name}')
|
||||||
elif detail:
|
except Exception:
|
||||||
self.task.instance.result_traceback = detail
|
raise RuntimeError(detail)
|
||||||
self.task.instance.save(update_fields=['result_traceback'])
|
|
||||||
else:
|
|
||||||
logger.warning(f'No result details or output from {self.task.instance.log_format}, status:\n{state_name}')
|
|
||||||
except Exception:
|
|
||||||
raise RuntimeError(detail)
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|||||||
63
awx/main/tasks/signals.py
Normal file
63
awx/main/tasks/signals.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import signal
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('awx.main.tasks.signals')
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['with_signal_handling', 'signal_callback']
|
||||||
|
|
||||||
|
|
||||||
|
class SignalState:
|
||||||
|
def reset(self):
|
||||||
|
self.sigterm_flag = False
|
||||||
|
self.is_active = False
|
||||||
|
self.original_sigterm = None
|
||||||
|
self.original_sigint = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def set_flag(self, *args):
|
||||||
|
"""Method to pass into the python signal.signal method to receive signals"""
|
||||||
|
self.sigterm_flag = True
|
||||||
|
|
||||||
|
def connect_signals(self):
|
||||||
|
self.original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||||
|
self.original_sigint = signal.getsignal(signal.SIGINT)
|
||||||
|
signal.signal(signal.SIGTERM, self.set_flag)
|
||||||
|
signal.signal(signal.SIGINT, self.set_flag)
|
||||||
|
self.is_active = True
|
||||||
|
|
||||||
|
def restore_signals(self):
|
||||||
|
signal.signal(signal.SIGTERM, self.original_sigterm)
|
||||||
|
signal.signal(signal.SIGINT, self.original_sigint)
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
|
signal_state = SignalState()
|
||||||
|
|
||||||
|
|
||||||
|
def signal_callback():
|
||||||
|
return signal_state.sigterm_flag
|
||||||
|
|
||||||
|
|
||||||
|
def with_signal_handling(f):
|
||||||
|
"""
|
||||||
|
Change signal handling to make signal_callback return True in event of SIGTERM or SIGINT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@functools.wraps(f)
|
||||||
|
def _wrapped(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
this_is_outermost_caller = False
|
||||||
|
if not signal_state.is_active:
|
||||||
|
signal_state.connect_signals()
|
||||||
|
this_is_outermost_caller = True
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
if this_is_outermost_caller:
|
||||||
|
signal_state.restore_signals()
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
@@ -10,12 +10,13 @@ from contextlib import redirect_stdout
|
|||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from distutils.version import LooseVersion as Version
|
from distutils.version import LooseVersion as Version
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction, DatabaseError, IntegrityError
|
from django.db import transaction, DatabaseError, IntegrityError
|
||||||
from django.db.models.fields.related import ForeignKey
|
from django.db.models.fields.related import ForeignKey
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now, timedelta
|
||||||
from django.utils.encoding import smart_str
|
from django.utils.encoding import smart_str
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -53,7 +54,8 @@ from awx.main.dispatch import get_local_queuename, reaper
|
|||||||
from awx.main.utils.common import (
|
from awx.main.utils.common import (
|
||||||
ignore_inventory_computed_fields,
|
ignore_inventory_computed_fields,
|
||||||
ignore_inventory_group_removal,
|
ignore_inventory_group_removal,
|
||||||
schedule_task_manager,
|
ScheduleWorkflowManager,
|
||||||
|
ScheduleTaskManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
from awx.main.utils.external_logging import reconfigure_rsyslog
|
from awx.main.utils.external_logging import reconfigure_rsyslog
|
||||||
@@ -103,7 +105,10 @@ def dispatch_startup():
|
|||||||
#
|
#
|
||||||
apply_cluster_membership_policies()
|
apply_cluster_membership_policies()
|
||||||
cluster_node_heartbeat()
|
cluster_node_heartbeat()
|
||||||
Metrics().clear_values()
|
reaper.startup_reaping()
|
||||||
|
reaper.reap_waiting(grace_period=0)
|
||||||
|
m = Metrics()
|
||||||
|
m.reset_values()
|
||||||
|
|
||||||
# Update Tower's rsyslog.conf file based on loggins settings in the db
|
# Update Tower's rsyslog.conf file based on loggins settings in the db
|
||||||
reconfigure_rsyslog()
|
reconfigure_rsyslog()
|
||||||
@@ -114,9 +119,9 @@ def inform_cluster_of_shutdown():
|
|||||||
this_inst = Instance.objects.get(hostname=settings.CLUSTER_HOST_ID)
|
this_inst = Instance.objects.get(hostname=settings.CLUSTER_HOST_ID)
|
||||||
this_inst.mark_offline(update_last_seen=True, errors=_('Instance received normal shutdown signal'))
|
this_inst.mark_offline(update_last_seen=True, errors=_('Instance received normal shutdown signal'))
|
||||||
try:
|
try:
|
||||||
reaper.reap(this_inst)
|
reaper.reap_waiting(this_inst, grace_period=0)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('failed to reap jobs for {}'.format(this_inst.hostname))
|
logger.exception('failed to reap waiting jobs for {}'.format(this_inst.hostname))
|
||||||
logger.warning('Normal shutdown signal for instance {}, ' 'removed self from capacity pool.'.format(this_inst.hostname))
|
logger.warning('Normal shutdown signal for instance {}, ' 'removed self from capacity pool.'.format(this_inst.hostname))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Encountered problem with normal shutdown signal.')
|
logger.exception('Encountered problem with normal shutdown signal.')
|
||||||
@@ -478,8 +483,8 @@ def inspect_execution_nodes(instance_list):
|
|||||||
execution_node_health_check.apply_async([hostname])
|
execution_node_health_check.apply_async([hostname])
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
@task(queue=get_local_queuename, bind_kwargs=['dispatch_time', 'worker_tasks'])
|
||||||
def cluster_node_heartbeat():
|
def cluster_node_heartbeat(dispatch_time=None, worker_tasks=None):
|
||||||
logger.debug("Cluster node heartbeat task.")
|
logger.debug("Cluster node heartbeat task.")
|
||||||
nowtime = now()
|
nowtime = now()
|
||||||
instance_list = list(Instance.objects.all())
|
instance_list = list(Instance.objects.all())
|
||||||
@@ -502,12 +507,23 @@ def cluster_node_heartbeat():
|
|||||||
|
|
||||||
if this_inst:
|
if this_inst:
|
||||||
startup_event = this_inst.is_lost(ref_time=nowtime)
|
startup_event = this_inst.is_lost(ref_time=nowtime)
|
||||||
|
last_last_seen = this_inst.last_seen
|
||||||
this_inst.local_health_check()
|
this_inst.local_health_check()
|
||||||
if startup_event and this_inst.capacity != 0:
|
if startup_event and this_inst.capacity != 0:
|
||||||
logger.warning('Rejoining the cluster as instance {}.'.format(this_inst.hostname))
|
logger.warning(f'Rejoining the cluster as instance {this_inst.hostname}. Prior last_seen {last_last_seen}')
|
||||||
return
|
return
|
||||||
|
elif not last_last_seen:
|
||||||
|
logger.warning(f'Instance does not have recorded last_seen, updating to {nowtime}')
|
||||||
|
elif (nowtime - last_last_seen) > timedelta(seconds=settings.CLUSTER_NODE_HEARTBEAT_PERIOD + 2):
|
||||||
|
logger.warning(f'Heartbeat skew - interval={(nowtime - last_last_seen).total_seconds():.4f}, expected={settings.CLUSTER_NODE_HEARTBEAT_PERIOD}')
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("Cluster Host Not Found: {}".format(settings.CLUSTER_HOST_ID))
|
if settings.AWX_AUTO_DEPROVISION_INSTANCES:
|
||||||
|
(changed, this_inst) = Instance.objects.register(ip_address=os.environ.get('MY_POD_IP'), node_type='control', uuid=settings.SYSTEM_UUID)
|
||||||
|
if changed:
|
||||||
|
logger.warning(f'Recreated instance record {this_inst.hostname} after unexpected removal')
|
||||||
|
this_inst.local_health_check()
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Cluster Host Not Found: {}".format(settings.CLUSTER_HOST_ID))
|
||||||
# IFF any node has a greater version than we do, then we'll shutdown services
|
# IFF any node has a greater version than we do, then we'll shutdown services
|
||||||
for other_inst in instance_list:
|
for other_inst in instance_list:
|
||||||
if other_inst.node_type in ('execution', 'hop'):
|
if other_inst.node_type in ('execution', 'hop'):
|
||||||
@@ -527,7 +543,9 @@ def cluster_node_heartbeat():
|
|||||||
|
|
||||||
for other_inst in lost_instances:
|
for other_inst in lost_instances:
|
||||||
try:
|
try:
|
||||||
reaper.reap(other_inst)
|
explanation = "Job reaped due to instance shutdown"
|
||||||
|
reaper.reap(other_inst, job_explanation=explanation)
|
||||||
|
reaper.reap_waiting(other_inst, grace_period=0, job_explanation=explanation)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('failed to reap jobs for {}'.format(other_inst.hostname))
|
logger.exception('failed to reap jobs for {}'.format(other_inst.hostname))
|
||||||
try:
|
try:
|
||||||
@@ -545,6 +563,15 @@ def cluster_node_heartbeat():
|
|||||||
else:
|
else:
|
||||||
logger.exception('Error marking {} as lost'.format(other_inst.hostname))
|
logger.exception('Error marking {} as lost'.format(other_inst.hostname))
|
||||||
|
|
||||||
|
# Run local reaper
|
||||||
|
if worker_tasks is not None:
|
||||||
|
active_task_ids = []
|
||||||
|
for task_list in worker_tasks.values():
|
||||||
|
active_task_ids.extend(task_list)
|
||||||
|
reaper.reap(instance=this_inst, excluded_uuids=active_task_ids)
|
||||||
|
if max(len(task_list) for task_list in worker_tasks.values()) <= 1:
|
||||||
|
reaper.reap_waiting(instance=this_inst, excluded_uuids=active_task_ids, ref_time=datetime.fromisoformat(dispatch_time))
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
@task(queue=get_local_queuename)
|
||||||
def awx_receptor_workunit_reaper():
|
def awx_receptor_workunit_reaper():
|
||||||
@@ -592,7 +619,8 @@ def awx_k8s_reaper():
|
|||||||
for group in InstanceGroup.objects.filter(is_container_group=True).iterator():
|
for group in InstanceGroup.objects.filter(is_container_group=True).iterator():
|
||||||
logger.debug("Checking for orphaned k8s pods for {}.".format(group))
|
logger.debug("Checking for orphaned k8s pods for {}.".format(group))
|
||||||
pods = PodManager.list_active_jobs(group)
|
pods = PodManager.list_active_jobs(group)
|
||||||
for job in UnifiedJob.objects.filter(pk__in=pods.keys()).exclude(status__in=ACTIVE_STATES):
|
time_cutoff = now() - timedelta(seconds=settings.K8S_POD_REAPER_GRACE_PERIOD)
|
||||||
|
for job in UnifiedJob.objects.filter(pk__in=pods.keys(), finished__lte=time_cutoff).exclude(status__in=ACTIVE_STATES):
|
||||||
logger.debug('{} is no longer active, reaping orphaned k8s pod'.format(job.log_format))
|
logger.debug('{} is no longer active, reaping orphaned k8s pod'.format(job.log_format))
|
||||||
try:
|
try:
|
||||||
pm = PodManager(job)
|
pm = PodManager(job)
|
||||||
@@ -660,6 +688,13 @@ def awx_periodic_scheduler():
|
|||||||
state.save()
|
state.save()
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_manager_success_or_error(instance):
|
||||||
|
if instance.unifiedjob_blocked_jobs.exists():
|
||||||
|
ScheduleTaskManager().schedule()
|
||||||
|
if instance.spawned_by_workflow:
|
||||||
|
ScheduleWorkflowManager().schedule()
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
@task(queue=get_local_queuename)
|
||||||
def handle_work_success(task_actual):
|
def handle_work_success(task_actual):
|
||||||
try:
|
try:
|
||||||
@@ -669,8 +704,7 @@ def handle_work_success(task_actual):
|
|||||||
return
|
return
|
||||||
if not instance:
|
if not instance:
|
||||||
return
|
return
|
||||||
|
schedule_manager_success_or_error(instance)
|
||||||
schedule_task_manager()
|
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
@task(queue=get_local_queuename)
|
||||||
@@ -695,7 +729,7 @@ def handle_work_error(task_id, *args, **kwargs):
|
|||||||
first_instance = instance
|
first_instance = instance
|
||||||
first_instance_type = each_task['type']
|
first_instance_type = each_task['type']
|
||||||
|
|
||||||
if instance.celery_task_id != task_id and not instance.cancel_flag and not instance.status == 'successful':
|
if instance.celery_task_id != task_id and not instance.cancel_flag and not instance.status in ('successful', 'failed'):
|
||||||
instance.status = 'failed'
|
instance.status = 'failed'
|
||||||
instance.failed = True
|
instance.failed = True
|
||||||
if not instance.job_explanation:
|
if not instance.job_explanation:
|
||||||
@@ -712,27 +746,7 @@ def handle_work_error(task_id, *args, **kwargs):
|
|||||||
# what the job complete message handler does then we may want to send a
|
# what the job complete message handler does then we may want to send a
|
||||||
# completion event for each job here.
|
# completion event for each job here.
|
||||||
if first_instance:
|
if first_instance:
|
||||||
schedule_task_manager()
|
schedule_manager_success_or_error(first_instance)
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
|
||||||
def handle_success_and_failure_notifications(job_id):
|
|
||||||
uj = UnifiedJob.objects.get(pk=job_id)
|
|
||||||
retries = 0
|
|
||||||
while retries < settings.AWX_NOTIFICATION_JOB_FINISH_MAX_RETRY:
|
|
||||||
if uj.finished:
|
|
||||||
uj.send_notification_templates('succeeded' if uj.status == 'successful' else 'failed')
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# wait a few seconds to avoid a race where the
|
|
||||||
# events are persisted _before_ the UJ.status
|
|
||||||
# changes from running -> successful
|
|
||||||
retries += 1
|
|
||||||
time.sleep(1)
|
|
||||||
uj = UnifiedJob.objects.get(pk=job_id)
|
|
||||||
|
|
||||||
logger.warning(f"Failed to even try to send notifications for job '{uj}' due to job not being in finished state.")
|
|
||||||
|
|
||||||
|
|
||||||
@task(queue=get_local_queuename)
|
@task(queue=get_local_queuename)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
- ansible.builtin.import_playbook: foo
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
- ansible.builtin.include: foo
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"ANSIBLE_JINJA2_NATIVE": "True",
|
"ANSIBLE_JINJA2_NATIVE": "True",
|
||||||
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
|
"ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS": "never",
|
||||||
"GCE_CREDENTIALS_FILE_PATH": "{{ file_reference }}",
|
"GCE_CREDENTIALS_FILE_PATH": "{{ file_reference }}",
|
||||||
|
"GOOGLE_APPLICATION_CREDENTIALS": "{{ file_reference }}",
|
||||||
"GCP_AUTH_KIND": "serviceaccount",
|
"GCP_AUTH_KIND": "serviceaccount",
|
||||||
"GCP_ENV_TYPE": "tower",
|
"GCP_ENV_TYPE": "tower",
|
||||||
"GCP_PROJECT": "fooo",
|
"GCP_PROJECT": "fooo",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def test_empty():
|
|||||||
"workflow_job_template": 0,
|
"workflow_job_template": 0,
|
||||||
"unified_job": 0,
|
"unified_job": 0,
|
||||||
"pending_jobs": 0,
|
"pending_jobs": 0,
|
||||||
|
"database_connections": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ EXPECTED_VALUES = {
|
|||||||
'awx_license_instance_total': 0,
|
'awx_license_instance_total': 0,
|
||||||
'awx_license_instance_free': 0,
|
'awx_license_instance_free': 0,
|
||||||
'awx_pending_jobs_total': 0,
|
'awx_pending_jobs_total': 0,
|
||||||
|
'awx_database_connections_total': 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -532,6 +532,49 @@ def test_vault_password_required(post, organization, admin):
|
|||||||
assert 'required fields (vault_password)' in j.job_explanation
|
assert 'required fields (vault_password)' in j.job_explanation
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_vault_id_immutable(post, patch, organization, admin):
|
||||||
|
vault = CredentialType.defaults['vault']()
|
||||||
|
vault.save()
|
||||||
|
response = post(
|
||||||
|
reverse('api:credential_list'),
|
||||||
|
{
|
||||||
|
'credential_type': vault.pk,
|
||||||
|
'organization': organization.id,
|
||||||
|
'name': 'Best credential ever',
|
||||||
|
'inputs': {'vault_id': 'password', 'vault_password': 'password'},
|
||||||
|
},
|
||||||
|
admin,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert Credential.objects.count() == 1
|
||||||
|
response = patch(
|
||||||
|
reverse('api:credential_detail', kwargs={'pk': response.data['id']}), {'inputs': {'vault_id': 'password2', 'vault_password': 'password'}}, admin
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.data['inputs'][0] == 'Vault IDs cannot be changed once they have been created.'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_patch_without_vault_id_valid(post, patch, organization, admin):
|
||||||
|
vault = CredentialType.defaults['vault']()
|
||||||
|
vault.save()
|
||||||
|
response = post(
|
||||||
|
reverse('api:credential_list'),
|
||||||
|
{
|
||||||
|
'credential_type': vault.pk,
|
||||||
|
'organization': organization.id,
|
||||||
|
'name': 'Best credential ever',
|
||||||
|
'inputs': {'vault_id': 'password', 'vault_password': 'password'},
|
||||||
|
},
|
||||||
|
admin,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert Credential.objects.count() == 1
|
||||||
|
response = patch(reverse('api:credential_detail', kwargs={'pk': response.data['id']}), {'name': 'worst_credential_ever'}, admin)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Net Credentials
|
# Net Credentials
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ from awx.api.versioning import reverse
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def ec2_source(inventory, project):
|
def ec2_source(inventory, project):
|
||||||
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'):
|
||||||
return inventory.inventory_sources.create(
|
return inventory.inventory_sources.create(name='some_source', source='ec2', source_project=project)
|
||||||
name='some_source', update_on_project_update=True, source='ec2', source_project=project, scm_last_revision=project.scm_revision
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user