mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-04 20:02:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fe9d207d0 | ||
|
|
6b3b498dee | ||
|
|
de36352cd1 | ||
|
|
1f56d6b1ce | ||
|
|
7df9fdf381 | ||
|
|
9084df7ebd | ||
|
|
0ae3fd19f4 | ||
|
|
8d8ae53a75 | ||
|
|
77f62992a2 | ||
|
|
ed633b11bd | ||
|
|
fa8f021ee9 | ||
|
|
d6f6b4c914 | ||
|
|
28b23c8ffd | ||
|
|
9976183a54 | ||
|
|
f2328624ab | ||
|
|
93e2161355 | ||
|
|
fa58d2f7fe | ||
|
|
0251dd3c7a | ||
|
|
253e71a85f | ||
|
|
d4cc80cb4c | ||
|
|
3c9dc45d68 | ||
|
|
b9ee0514c6 | ||
|
|
537bb5a815 | ||
|
|
2e227a87c5 | ||
|
|
6fe5c8e25c | ||
|
|
7fe03d96f5 | ||
|
|
2c59356e66 | ||
|
|
c68d57feb5 | ||
|
|
5395dd488a | ||
|
|
3048ed8e5b | ||
|
|
468f57ebed | ||
|
|
e42fca75ec | ||
|
|
1a18432cee | ||
|
|
b2e84fde08 | ||
|
|
cf7269c123 | ||
|
|
ef1e6932b4 | ||
|
|
aadd90810f | ||
|
|
fc2a5ff4fe | ||
|
|
ff78912764 | ||
|
|
256df1ef3e | ||
|
|
0333d95479 | ||
|
|
e3113df0a2 | ||
|
|
24d069e1dd | ||
|
|
3a000458b2 | ||
|
|
d5b7d386ea | ||
|
|
624ff628bd | ||
|
|
5ea0645e31 | ||
|
|
ac2bd28d25 | ||
|
|
c706f9ed92 | ||
|
|
2327f96479 | ||
|
|
ead77f0d1f | ||
|
|
52a9034676 | ||
|
|
e8e9e07e6b | ||
|
|
cb97fc4c98 | ||
|
|
75fd803443 | ||
|
|
c0a0cdca34 | ||
|
|
d75d44c601 | ||
|
|
276a116696 | ||
|
|
b1010a194a | ||
|
|
0faa781dd1 | ||
|
|
60b66c4668 | ||
|
|
393e6095fa | ||
|
|
380a9b653c | ||
|
|
1920dc2937 | ||
|
|
bfdcd4a42c | ||
|
|
139a4b4089 | ||
|
|
14e2718275 | ||
|
|
6befea0757 | ||
|
|
ed2a696c01 | ||
|
|
6968409ae5 | ||
|
|
f29855f765 | ||
|
|
65482d8eec | ||
|
|
21e5b636a8 | ||
|
|
a6ada401b5 | ||
|
|
98d8e78dcb | ||
|
|
f9fe4d2793 | ||
|
|
81cc746e19 | ||
|
|
aa4adc34a9 | ||
|
|
dc823bc21b | ||
|
|
297a41d58a | ||
|
|
72908e55aa | ||
|
|
668b557fd5 | ||
|
|
f5ce0438f9 | ||
|
|
21616fe53d | ||
|
|
8e6e9d5cab | ||
|
|
3b15e14625 | ||
|
|
89531bbab8 | ||
|
|
c13d3de023 | ||
|
|
dd9c21ed75 | ||
|
|
aaffe4e08a | ||
|
|
d3fedeb201 | ||
|
|
bd42df3ead | ||
|
|
cf7b8363da | ||
|
|
d3752f0417 | ||
|
|
6b19bb652e | ||
|
|
2c5decfb57 | ||
|
|
88ba698758 | ||
|
|
72491b2445 | ||
|
|
8eef466949 | ||
|
|
30034e7aa3 | ||
|
|
1d707a7220 | ||
|
|
512b268815 | ||
|
|
e52460e991 | ||
|
|
5db3ae9c92 | ||
|
|
79bc8d33fd | ||
|
|
581eed61e5 | ||
|
|
d27dc834db | ||
|
|
2ab83c0b88 | ||
|
|
fd0326475d | ||
|
|
f185064560 | ||
|
|
bdf9075639 | ||
|
|
65b10cea09 | ||
|
|
9147ec2f52 | ||
|
|
ba3fb1197f | ||
|
|
889409de8d | ||
|
|
43973e1d06 | ||
|
|
e1be5c22c8 | ||
|
|
79cdf814ed | ||
|
|
707a45e639 | ||
|
|
52cd21dcb1 | ||
|
|
503a946d85 | ||
|
|
ac9565fa19 | ||
|
|
f40e0a53e1 | ||
|
|
2d4b0cb746 | ||
|
|
616ba129b2 | ||
|
|
6a91487d21 | ||
|
|
8ee3b29991 | ||
|
|
a36a8d112b | ||
|
|
cd8c12ecac | ||
|
|
52d44a7435 | ||
|
|
5e89b2bdf5 | ||
|
|
a259b945eb | ||
|
|
bf8c495d3b | ||
|
|
05790b494e | ||
|
|
a7786a6874 | ||
|
|
3d1c542a05 | ||
|
|
e587aa7d79 | ||
|
|
6a30d92ba7 | ||
|
|
3a0a7eaf14 | ||
|
|
b383fb9311 | ||
|
|
68ae23df82 | ||
|
|
f23f1912c2 | ||
|
|
692c7f7256 | ||
|
|
f391eecdd6 | ||
|
|
a079f3fc90 | ||
|
|
cdb1bd0cbf | ||
|
|
87c48830c5 | ||
|
|
ecc8017f71 | ||
|
|
fc8c23fd66 | ||
|
|
5adafeaa96 | ||
|
|
aeb55236c1 | ||
|
|
f623d29e12 | ||
|
|
9ab744e784 | ||
|
|
9fef8d89a7 | ||
|
|
85857acaf9 | ||
|
|
f5c98fb700 | ||
|
|
f79c35b038 | ||
|
|
6173c012e6 | ||
|
|
4cefc0484b | ||
|
|
ac9b8c2705 | ||
|
|
085bb084eb | ||
|
|
8f8a80785a | ||
|
|
d1217d6056 | ||
|
|
e6d39f6015 | ||
|
|
ec5fbd668f | ||
|
|
688b7f54bc | ||
|
|
c676d883d9 | ||
|
|
6feb11ffb9 | ||
|
|
f14ad4d7b1 | ||
|
|
1285132326 | ||
|
|
2b1ae0be72 | ||
|
|
28d9ff1c9e | ||
|
|
a98f7d859b | ||
|
|
860fac9271 | ||
|
|
e904f9d37f | ||
|
|
6dc2c1f13c | ||
|
|
a48bccab38 | ||
|
|
96a388d1e7 | ||
|
|
314178e0ac | ||
|
|
cca3516e65 | ||
|
|
05cf4a40c3 | ||
|
|
e5b5a99c52 | ||
|
|
f6d16fd054 | ||
|
|
01584b974a | ||
|
|
106454d47b | ||
|
|
80456679f1 | ||
|
|
575e759718 | ||
|
|
617d85e427 | ||
|
|
ede91f30b8 | ||
|
|
e9b8fd28db | ||
|
|
9b712982e1 | ||
|
|
85e5c5f29b | ||
|
|
7b1736fe3e | ||
|
|
a4148c6616 | ||
|
|
acbe53ad4a | ||
|
|
542ff12890 | ||
|
|
55e830904d | ||
|
|
a640993b94 | ||
|
|
b6500c147e | ||
|
|
92c6a000b5 | ||
|
|
f6f811f5ea | ||
|
|
0d063d519c | ||
|
|
fc7c3181bc | ||
|
|
a1f90ddaf2 | ||
|
|
be6ef0069b | ||
|
|
ae809142f6 | ||
|
|
3e1729a262 | ||
|
|
88b942a543 | ||
|
|
ba6f691f90 | ||
|
|
5e6ed0a19b | ||
|
|
d1c7fa0158 | ||
|
|
5596cc9149 | ||
|
|
c316e901cd | ||
|
|
1e0c6e70bf | ||
|
|
0313e58bd8 | ||
|
|
f2ef3bebbd | ||
|
|
d403f25fbb | ||
|
|
ff11a6d462 | ||
|
|
e82d95f1cf | ||
|
|
4e00fcd746 | ||
|
|
c5458f34d9 | ||
|
|
85650ca0e8 | ||
|
|
25953b03bd | ||
|
|
e99b5f32ee | ||
|
|
975a3b99cf | ||
|
|
a4fb46f861 | ||
|
|
dede474ff4 | ||
|
|
2a02154f17 | ||
|
|
ecd0b9c54b | ||
|
|
1ff10e7a7f | ||
|
|
5c90f40ef6 | ||
|
|
ee5729aba2 | ||
|
|
a42dd9d28c | ||
|
|
2014105343 | ||
|
|
744c0165e2 | ||
|
|
723f53a1f1 | ||
|
|
fd74b3cacc | ||
|
|
11326badb2 | ||
|
|
44ab9d506d | ||
|
|
af29bc9274 | ||
|
|
9f977ac0be | ||
|
|
761385a107 | ||
|
|
354966180c | ||
|
|
fab4aee37a | ||
|
|
170293c67a | ||
|
|
9340def0e7 | ||
|
|
b4bd674a80 | ||
|
|
6b62aa2608 | ||
|
|
541eb6379e | ||
|
|
5c6afd1fcb | ||
|
|
23ac5ba228 | ||
|
|
e255b4cc64 | ||
|
|
e909e1ce67 | ||
|
|
8269d9aaaf | ||
|
|
aa8a28294d | ||
|
|
3b983a0a78 | ||
|
|
f2854ebece | ||
|
|
8eeafa1f18 | ||
|
|
6c2e268c4b | ||
|
|
12f424911d | ||
|
|
1ff63fa955 | ||
|
|
c24d022f40 | ||
|
|
6bb9a1ebb0 | ||
|
|
e649a92f30 | ||
|
|
bab512281c | ||
|
|
3686d613e6 | ||
|
|
896817affa | ||
|
|
b2067e18a5 | ||
|
|
d152fc42d3 | ||
|
|
69518f701f | ||
|
|
281dfdf638 | ||
|
|
17fe62062e | ||
|
|
8b1cbbbb0d | ||
|
|
fc41b04e87 | ||
|
|
e233d1856b | ||
|
|
257c2728fb | ||
|
|
2f48689087 | ||
|
|
779dd7fb26 | ||
|
|
225438209e | ||
|
|
adda62d1ed | ||
|
|
6556d560fa | ||
|
|
5ece466d1e | ||
|
|
b879adf54c | ||
|
|
c15834ba63 | ||
|
|
39891e5177 | ||
|
|
e032d869ff | ||
|
|
1f0c72b90b | ||
|
|
03770129a3 | ||
|
|
6422d6ecd5 | ||
|
|
d17c017ccf | ||
|
|
6699a4afbb | ||
|
|
b3319268a7 | ||
|
|
00ebb0b2f9 | ||
|
|
7aacd08a96 | ||
|
|
3298b55cc6 | ||
|
|
396b44eacb | ||
|
|
d9bd8c47de | ||
|
|
dbcc4de6e1 | ||
|
|
61168b9abd | ||
|
|
ff81c4269c | ||
|
|
c04fd65b4f | ||
|
|
203496f14f | ||
|
|
b0ffcd2049 | ||
|
|
c0046cb24f | ||
|
|
4ffbaa26d3 | ||
|
|
a1ee7fa744 | ||
|
|
0d346c6bef | ||
|
|
53e9f08fe7 | ||
|
|
9864fae62a | ||
|
|
1e0595aac2 | ||
|
|
c471bccdab | ||
|
|
b75f2789b3 | ||
|
|
a5371b05dc | ||
|
|
6b41ff4247 | ||
|
|
d687a79ad8 | ||
|
|
09f9ee18fc | ||
|
|
06d06041c7 | ||
|
|
0d7c4554bc | ||
|
|
08d2b22c14 | ||
|
|
92cde1bac3 | ||
|
|
ae37003df8 | ||
|
|
40f967d87e | ||
|
|
62b8b4e2ae | ||
|
|
e9b310743a | ||
|
|
ab1b6ce7a1 | ||
|
|
a191096ee4 | ||
|
|
c00207bd20 | ||
|
|
bb8f5b5c06 | ||
|
|
153743b80d | ||
|
|
6c05688196 | ||
|
|
37a6c02111 | ||
|
|
092a62e2dd | ||
|
|
3b12e68e8c | ||
|
|
37ad54232a | ||
|
|
d5f4691be7 | ||
|
|
06fbea8735 | ||
|
|
56844d7f04 | ||
|
|
96b62c59cc | ||
|
|
5ccc1451ce | ||
|
|
90f080744a | ||
|
|
c7a134b00a | ||
|
|
441c34514d | ||
|
|
7213fb2553 | ||
|
|
43041846eb | ||
|
|
3970bafbe0 | ||
|
|
a0379fee0b | ||
|
|
0e01b9f652 | ||
|
|
34fcc64550 | ||
|
|
f8ebe93730 | ||
|
|
aed77ecefe | ||
|
|
1dc9e92f74 | ||
|
|
f9e36f0b0a | ||
|
|
c991b8280a | ||
|
|
9ffd154a2a | ||
|
|
133929fda6 | ||
|
|
65f47ec646 | ||
|
|
0a7a598210 | ||
|
|
d8741a8c5d | ||
|
|
5f1aa4cde7 | ||
|
|
58e16c686e | ||
|
|
29b4913047 | ||
|
|
ad56c02b21 | ||
|
|
5d87106e69 | ||
|
|
77a6d1c397 | ||
|
|
1384a5e2cc | ||
|
|
6066442cc6 | ||
|
|
d7cc914c35 | ||
|
|
361f26bf70 | ||
|
|
cb585632c6 | ||
|
|
311952cdf5 | ||
|
|
423156214a | ||
|
|
818ab1308a | ||
|
|
95bf21b6de | ||
|
|
e138f4c800 | ||
|
|
1385ab3a7c | ||
|
|
91fe0b400a | ||
|
|
7bf1e1e34d | ||
|
|
ec934c8c2d | ||
|
|
3e3e0916f2 | ||
|
|
83ad947b2c | ||
|
|
1511680a95 | ||
|
|
3e9c9eb25d | ||
|
|
ff6f023238 | ||
|
|
7e00904dce | ||
|
|
70d4101243 | ||
|
|
e44db4897b | ||
|
|
8775fd30c5 | ||
|
|
cfb90a094c | ||
|
|
4332fec8a3 | ||
|
|
440b6be6ef | ||
|
|
bc51adcd8f | ||
|
|
3071095109 | ||
|
|
0a06a81871 | ||
|
|
6c269bf7a5 | ||
|
|
e63ec2ad7b | ||
|
|
29778aa709 | ||
|
|
b1dc624e7d | ||
|
|
e52d194b9a | ||
|
|
f6cff70298 | ||
|
|
ab8c4a56ea | ||
|
|
b5a2a3f52b | ||
|
|
c6f919b085 | ||
|
|
b720b4a8de | ||
|
|
a2b96bf6a4 | ||
|
|
bdb4544ede | ||
|
|
62ddd7599b | ||
|
|
7e32000302 | ||
|
|
241d7a18bf | ||
|
|
0000086a93 | ||
|
|
06968696e3 | ||
|
|
3c814b24b4 | ||
|
|
0149327367 | ||
|
|
947b5291e9 | ||
|
|
cc7e626785 | ||
|
|
c1fb72ed0e | ||
|
|
456f7e64f6 | ||
|
|
700a4de2ee | ||
|
|
d06cf0f6f6 | ||
|
|
27a2c35eff | ||
|
|
1f0c78608d | ||
|
|
023c512df3 | ||
|
|
f648521a73 | ||
|
|
b80a255f93 | ||
|
|
fae36bc09d | ||
|
|
53191b9e7a | ||
|
|
2d48b87ac1 | ||
|
|
c3cb726cbf | ||
|
|
aedd3085a9 | ||
|
|
2b3f844eef | ||
|
|
0eb8c5db20 | ||
|
|
f66eea2eb7 | ||
|
|
5ac7d976ab | ||
|
|
7ef834411d | ||
|
|
e87a1e8f51 | ||
|
|
f6d43e7f5f | ||
|
|
cfe0f31e14 | ||
|
|
bde0cf8da6 | ||
|
|
9e03f59034 | ||
|
|
2868f7b8ac | ||
|
|
d8efdb440b | ||
|
|
98998fcbd9 | ||
|
|
b85e3a0f49 | ||
|
|
944df3f93a | ||
|
|
b18ed8c3d6 | ||
|
|
95b7950ca3 | ||
|
|
d07b970f8b | ||
|
|
9247e6d887 | ||
|
|
20e355c09f | ||
|
|
c09f27d489 | ||
|
|
3be6dfbfc8 | ||
|
|
f18238aad7 | ||
|
|
8cd262b295 | ||
|
|
4213878884 | ||
|
|
8cdb519ccb | ||
|
|
5ee0506212 | ||
|
|
e5bf63b31f | ||
|
|
7a6a6f93dc | ||
|
|
d3da2da48c | ||
|
|
85bea617d6 | ||
|
|
f73154a2c7 | ||
|
|
2efd775559 | ||
|
|
64095689ac | ||
|
|
d4c4dce832 | ||
|
|
163c964314 | ||
|
|
746c5a61df | ||
|
|
85bf3b8584 | ||
|
|
ab2dd5dc02 | ||
|
|
3756a6de9a | ||
|
|
36a8e84491 | ||
|
|
94a8e44ca5 | ||
|
|
3ee8f8cd58 | ||
|
|
dd62e5ce7d | ||
|
|
ee919dad24 | ||
|
|
d67eb39a65 | ||
|
|
b6bce3cae4 | ||
|
|
fc5884cdc1 | ||
|
|
45fdceaaa4 | ||
|
|
3e9ac6750b | ||
|
|
68eb53c2d9 | ||
|
|
f6d3ad4337 | ||
|
|
2d2789a4b7 | ||
|
|
efaeed3706 | ||
|
|
f8cabb4700 | ||
|
|
f363ef4395 | ||
|
|
e8b8f95c5b | ||
|
|
1cbcdd69aa | ||
|
|
8757bbeb4f | ||
|
|
ccdc21504a | ||
|
|
9861a85ccd | ||
|
|
2aa6e0ba44 | ||
|
|
e3c9162982 | ||
|
|
f2bb006728 | ||
|
|
bef2f837c2 | ||
|
|
d4e98d16b1 | ||
|
|
41ef07ac55 | ||
|
|
32b71bdd3d | ||
|
|
7efb63c478 | ||
|
|
d8ae19c76e | ||
|
|
750421a12f | ||
|
|
2c8ebcaa9f | ||
|
|
7d17220294 | ||
|
|
a32a7577cc | ||
|
|
84200fadb4 | ||
|
|
7e458bae2e | ||
|
|
1bc38884ce | ||
|
|
12ab5ae10a | ||
|
|
152c3ddfe0 | ||
|
|
e38c085e5f | ||
|
|
4bce49eb46 | ||
|
|
ba9ae51615 | ||
|
|
4dc19ed4d8 | ||
|
|
26cfc50555 | ||
|
|
e938a25da3 | ||
|
|
ac42bea70f | ||
|
|
e2fa8adcae | ||
|
|
99adc343e8 | ||
|
|
6496318452 | ||
|
|
1e97f732ac | ||
|
|
8a774d05c7 | ||
|
|
592354b0e0 | ||
|
|
6157f34ced | ||
|
|
74c0321956 | ||
|
|
98ae1f3626 | ||
|
|
14513cc898 | ||
|
|
b70e968f73 | ||
|
|
d286b650d7 | ||
|
|
784ce13c14 | ||
|
|
1c42203166 | ||
|
|
2390257515 | ||
|
|
3e1ae3c4ee | ||
|
|
178540a94f | ||
|
|
9f6296501a | ||
|
|
a35700f54f | ||
|
|
068485d339 | ||
|
|
a9241d7a6c | ||
|
|
5210fc415a | ||
|
|
2b1ae1620d | ||
|
|
5369c3acce | ||
|
|
f07482f201 | ||
|
|
45756cb45f | ||
|
|
11b1c895f4 | ||
|
|
ea6f6dbf6e | ||
|
|
de9c29a9e9 | ||
|
|
f36b5e22a8 | ||
|
|
d15876d940 | ||
|
|
427c3dc2b9 | ||
|
|
dace78214a | ||
|
|
7aec721eb5 | ||
|
|
22098042ba | ||
|
|
c393a56b92 | ||
|
|
503302cea3 | ||
|
|
20acf0852a | ||
|
|
1f75147d51 | ||
|
|
e978bac91a | ||
|
|
61fae8f6f7 | ||
|
|
6d82cf4f15 | ||
|
|
bbe811e873 | ||
|
|
e89f2596b2 | ||
|
|
b7b412b45d | ||
|
|
111593bc38 | ||
|
|
ce1a3de719 | ||
|
|
85f0123c5d | ||
|
|
08ae134860 | ||
|
|
69b2dc31d0 | ||
|
|
a42d402e3a | ||
|
|
c83559643d | ||
|
|
40c0701ab1 | ||
|
|
227780aba7 | ||
|
|
628dbe3aa3 | ||
|
|
72b596ff4b | ||
|
|
c32a9880d9 | ||
|
|
f13ce25071 | ||
|
|
a230e8b067 | ||
|
|
63ae1ba68f | ||
|
|
7e97bcb25b | ||
|
|
29260a0e41 | ||
|
|
8d9c0083cd | ||
|
|
ac391cdb35 | ||
|
|
f336eb65f0 | ||
|
|
f7220826a6 | ||
|
|
1a20638097 | ||
|
|
9a94045c33 | ||
|
|
3c962fef11 | ||
|
|
b75d373f31 | ||
|
|
5661891d83 | ||
|
|
c9caea0b7a | ||
|
|
40fdaf0145 | ||
|
|
9ba5a797c4 | ||
|
|
4ca430db3a | ||
|
|
9558bd3609 | ||
|
|
3a90f218ac | ||
|
|
5197d84328 | ||
|
|
6a9a025199 | ||
|
|
aba6da22db | ||
|
|
766cfa0779 | ||
|
|
8772b286da | ||
|
|
b035373efb | ||
|
|
8bbb475614 | ||
|
|
2898ecbfdb | ||
|
|
5063c7a006 | ||
|
|
def0987711 | ||
|
|
b938e14fc5 | ||
|
|
248ac5dec6 | ||
|
|
52632abbd6 | ||
|
|
ada0f1109f | ||
|
|
87720f14f1 | ||
|
|
e4c517f258 | ||
|
|
816efa533e | ||
|
|
511bfe52ea | ||
|
|
31254b1c9c | ||
|
|
8840490da0 | ||
|
|
af1a648689 | ||
|
|
c2ef5a54a6 | ||
|
|
4ffc883c32 | ||
|
|
ee6b3bb232 | ||
|
|
113659c0e3 | ||
|
|
9e6978cf10 | ||
|
|
c9cdac58aa | ||
|
|
d487f33700 | ||
|
|
6fc9f9003b | ||
|
|
7d2d430320 | ||
|
|
690ace1e57 | ||
|
|
d25747646e | ||
|
|
413ac5e80e | ||
|
|
9bc83a13ef | ||
|
|
fdbd420961 | ||
|
|
59ab7784a7 | ||
|
|
c2ad35ca73 | ||
|
|
3000c1cd66 | ||
|
|
1b0d6a086e | ||
|
|
db0e609259 | ||
|
|
d0f8df39db | ||
|
|
653dee192c | ||
|
|
ea3b579089 | ||
|
|
4c8b5b1188 | ||
|
|
728baf3714 | ||
|
|
4e572b8c21 | ||
|
|
ebce0b8511 | ||
|
|
2cbeacefb2 | ||
|
|
b55eca753d | ||
|
|
3ad7407e07 | ||
|
|
f07dbfe10f | ||
|
|
84eb6d2167 | ||
|
|
36b7bf1bf9 | ||
|
|
7f21cae196 | ||
|
|
8220edf3b6 | ||
|
|
ab730977ab | ||
|
|
2500fb0cfc | ||
|
|
d6f814ba52 | ||
|
|
307c7cbb83 | ||
|
|
abf05493b9 | ||
|
|
c7480fc4e7 | ||
|
|
6890d770bf | ||
|
|
1e9d97f1aa | ||
|
|
f5d2074a9a | ||
|
|
bec077dd9e | ||
|
|
cf2c0637a7 | ||
|
|
00350655bf | ||
|
|
abbb3afc0c | ||
|
|
1ca028481d | ||
|
|
1056cc50c2 | ||
|
|
6677743a82 | ||
|
|
89fdfd0952 | ||
|
|
983ed38e3b | ||
|
|
54d9283eba | ||
|
|
346c9988a9 | ||
|
|
cd817524ff | ||
|
|
8b0ca0d06b | ||
|
|
84faf0bce4 | ||
|
|
3254ca6d05 | ||
|
|
3e806afe8c | ||
|
|
017b73fffd | ||
|
|
18236e06bc | ||
|
|
664ef981e5 | ||
|
|
51ed84f9b1 | ||
|
|
f3add354e6 | ||
|
|
638552e5f8 | ||
|
|
3489dc0530 | ||
|
|
ceb783bcf2 | ||
|
|
51ce1184da | ||
|
|
c9479c6ac1 | ||
|
|
f2c5ab1636 | ||
|
|
1a8efb96a5 | ||
|
|
7e04500e0c | ||
|
|
e0e2f10471 | ||
|
|
2167934289 | ||
|
|
3023ac33ec | ||
|
|
03c8dc9eb6 | ||
|
|
39bb4f8616 | ||
|
|
f3d97724c5 | ||
|
|
00b00b9db7 | ||
|
|
7a9878e458 | ||
|
|
6efe322c44 | ||
|
|
63f1e631b9 | ||
|
|
e83e66caaf | ||
|
|
ea5831d1bf | ||
|
|
3708e9cf79 | ||
|
|
61041bcbfd | ||
|
|
118f7c1cf6 | ||
|
|
3414a77aa4 | ||
|
|
d7f2f13cd9 | ||
|
|
631b1cf253 | ||
|
|
c8e5421d75 | ||
|
|
aba805d4d9 | ||
|
|
21ae8e94a4 | ||
|
|
cc12c625a6 | ||
|
|
87403a2e48 | ||
|
|
9089f79342 | ||
|
|
3198209493 | ||
|
|
c496f111b6 | ||
|
|
fbafb92f49 | ||
|
|
03f2a195a6 | ||
|
|
f17f87c94d | ||
|
|
4e4b19213b | ||
|
|
e587e27144 | ||
|
|
bff791581c | ||
|
|
de7fc4bd49 | ||
|
|
80af670794 | ||
|
|
1ac1f58afa | ||
|
|
370b45a2d8 | ||
|
|
5f3729568e | ||
|
|
c3c29c8692 | ||
|
|
9ebc959530 | ||
|
|
35d61beaa1 | ||
|
|
6a7565c858 | ||
|
|
4b384e64ee | ||
|
|
5155c0c0c8 | ||
|
|
e633ecadcb | ||
|
|
85794a8989 | ||
|
|
11449239cb | ||
|
|
69b76116fd | ||
|
|
873a3cd8e2 | ||
|
|
dd16682603 | ||
|
|
7b2d345e83 | ||
|
|
cf830ef143 | ||
|
|
bbec8d34d1 | ||
|
|
d33b131c4d | ||
|
|
0fd3be15c1 | ||
|
|
8b8fed2564 | ||
|
|
b581d4c498 | ||
|
|
5be6c14221 | ||
|
|
04cfa2c587 | ||
|
|
a552da6f91 | ||
|
|
56d7a1839b | ||
|
|
b026332032 | ||
|
|
7215c551d4 | ||
|
|
bb817ad1f9 | ||
|
|
155cafcb73 | ||
|
|
dad116c8b5 | ||
|
|
34c549b738 | ||
|
|
2d022a873d | ||
|
|
a1bac7aeec | ||
|
|
716b9ae871 | ||
|
|
b46cc4bd6d | ||
|
|
ee3d8db577 | ||
|
|
8169aeadce | ||
|
|
0460db741e | ||
|
|
539f91189e | ||
|
|
522860e352 | ||
|
|
216298377a | ||
|
|
07321333ca | ||
|
|
f319ba4b2b | ||
|
|
47ef6a96c5 | ||
|
|
10e42c3c9a | ||
|
|
c2936d4587 | ||
|
|
9f09272889 | ||
|
|
9ac9556940 | ||
|
|
8902a2574f | ||
|
|
51851136ab | ||
|
|
9ef621e049 | ||
|
|
9a3f254b10 | ||
|
|
815d98ab9f | ||
|
|
ded2af3a57 | ||
|
|
5a892db7c9 | ||
|
|
82f69be859 | ||
|
|
16ab9808ac | ||
|
|
316eb2f104 | ||
|
|
11479bcfe1 | ||
|
|
3bc45535e4 | ||
|
|
66c621fe7a | ||
|
|
5604094907 | ||
|
|
abf436c352 | ||
|
|
1a5fec13ec | ||
|
|
8879abf7bb | ||
|
|
73bca5f130 | ||
|
|
eb5105f582 | ||
|
|
c7efdd2e59 | ||
|
|
693e6ae481 | ||
|
|
07713d5365 | ||
|
|
053b6c81f8 | ||
|
|
f5ad4072b5 | ||
|
|
4ea1bf4a80 | ||
|
|
1bf8dd00c9 | ||
|
|
7305713c80 | ||
|
|
a02057fd52 | ||
|
|
b629938cf1 | ||
|
|
65d55b0f58 | ||
|
|
0d2d639795 | ||
|
|
957fef8c62 | ||
|
|
bc913ff5eb | ||
|
|
b927db8708 | ||
|
|
8ff0d68c89 | ||
|
|
f0d7d4f8f7 | ||
|
|
113c31ab09 | ||
|
|
fc14ff5c33 | ||
|
|
feb13949e1 | ||
|
|
41ae752da1 | ||
|
|
6b70c3934e | ||
|
|
1e00eeac4f | ||
|
|
19a7068fee | ||
|
|
f6274e360b | ||
|
|
42c927ecfd | ||
|
|
a60e065d33 | ||
|
|
74df2969dc | ||
|
|
3f75b94ea1 | ||
|
|
747b32489f | ||
|
|
4a3dd8cc43 | ||
|
|
c343b5ade1 | ||
|
|
69c9984ff7 | ||
|
|
be396e0054 | ||
|
|
f788f205b8 | ||
|
|
f464b62190 | ||
|
|
ebb25e5a70 | ||
|
|
cff386053b | ||
|
|
0cf62149fd | ||
|
|
3b6615194f | ||
|
|
b4f4d98e85 | ||
|
|
c6b2b74bf0 | ||
|
|
f04b84cb89 | ||
|
|
85b88f709a | ||
|
|
12632c027e | ||
|
|
8639ed1525 | ||
|
|
374d95921c | ||
|
|
8094d69fb4 | ||
|
|
f3a3332327 | ||
|
|
5734947e17 | ||
|
|
dd28d2d341 | ||
|
|
b2398a1cae | ||
|
|
b36e5292ff | ||
|
|
c7256042e4 | ||
|
|
65f406f13e | ||
|
|
8d456f7ca1 | ||
|
|
37acfbc369 | ||
|
|
32e67ba54b | ||
|
|
75c528e5c2 | ||
|
|
6b50763e54 | ||
|
|
ae22df1f9d | ||
|
|
e82216eb5b | ||
|
|
7a4a6e114d | ||
|
|
55de7d1cbb | ||
|
|
73f484834a | ||
|
|
f82d5d1fb0 | ||
|
|
c7dba81f8f | ||
|
|
ee6d53acc5 | ||
|
|
951acfb14e | ||
|
|
f6eccfb25c | ||
|
|
c739699ec7 | ||
|
|
1f9d2acef5 | ||
|
|
346d9b15f6 | ||
|
|
b9e5b03d6b | ||
|
|
3fc99a35b6 | ||
|
|
071d6081f8 | ||
|
|
af10486436 | ||
|
|
db1ab725d6 | ||
|
|
85b2d2114a | ||
|
|
7e9f16fdb3 | ||
|
|
cb07f34626 | ||
|
|
a40ad157dd | ||
|
|
badd0f8771 | ||
|
|
9b5cefb81a | ||
|
|
0cd2efdd4e | ||
|
|
f40910125c | ||
|
|
20ee21c08f | ||
|
|
98da85bf00 | ||
|
|
284851627e | ||
|
|
96f3d82e15 | ||
|
|
b7e053b88a | ||
|
|
f80e6c3e39 | ||
|
|
e5fc9c7188 | ||
|
|
37a461c5e5 | ||
|
|
40788855db | ||
|
|
c91e7ecbac | ||
|
|
6d40cfc8f9 | ||
|
|
8e5920faf0 | ||
|
|
872d90e75a | ||
|
|
9cbcd0d764 | ||
|
|
064b169ae9 | ||
|
|
c609fa0e12 | ||
|
|
a4cb9936a5 | ||
|
|
07f88659ea | ||
|
|
ea5fc041b8 | ||
|
|
ddb0b5a400 | ||
|
|
21babcc14d | ||
|
|
aac75a0753 | ||
|
|
54eef11334 | ||
|
|
3be15211b1 | ||
|
|
8e396f957f | ||
|
|
08598d2863 | ||
|
|
147c401864 | ||
|
|
e24308c017 | ||
|
|
e52f52388a | ||
|
|
2ea261dca3 | ||
|
|
f80e0b6784 | ||
|
|
5faf55a746 | ||
|
|
dcf00cc6b9 | ||
|
|
f641bab0e0 | ||
|
|
cbd4aff7a4 | ||
|
|
4f651ffed5 | ||
|
|
d137bb3d31 | ||
|
|
4e6d43f6a5 | ||
|
|
c83f84398b | ||
|
|
d2ac3b7638 | ||
|
|
028026a9a0 | ||
|
|
e9957415ed | ||
|
|
22e8bf2532 | ||
|
|
c1012356f2 | ||
|
|
fda584f65b | ||
|
|
8efccc3d03 | ||
|
|
4fd987fdd5 | ||
|
|
0fa112de56 | ||
|
|
2398b605b2 | ||
|
|
f421ac1cb1 | ||
|
|
889fc4daf4 | ||
|
|
438cb935d0 | ||
|
|
c40a335301 | ||
|
|
72c4dcd0be | ||
|
|
135df4b2c5 | ||
|
|
db4ef3f29b | ||
|
|
41099960d6 | ||
|
|
ad24cd9c15 | ||
|
|
b7154ffee9 | ||
|
|
e18ff82eea | ||
|
|
18bf0c3e7e | ||
|
|
c93c0dbcef | ||
|
|
5b789d2433 | ||
|
|
e57a2f3aae | ||
|
|
eb7cc548fa | ||
|
|
a17f4120e3 | ||
|
|
57a69e1f92 | ||
|
|
bfe7794d98 | ||
|
|
ff51c2239e | ||
|
|
f7239889ca | ||
|
|
1f1a2176fd | ||
|
|
25cb81d740 | ||
|
|
4c08ef6ea0 | ||
|
|
d14b968930 | ||
|
|
d27012806b | ||
|
|
72f77069ca | ||
|
|
eb869d1250 | ||
|
|
0b82e91dd7 | ||
|
|
6e303e35ce | ||
|
|
0ae601f5e7 | ||
|
|
4a5dd8a94e | ||
|
|
55999da58a | ||
|
|
328a690712 | ||
|
|
46af79eb51 | ||
|
|
118a757ac7 | ||
|
|
2922fc235d | ||
|
|
58fb192b29 | ||
|
|
7ec6d06dc3 | ||
|
|
488b9b6521 | ||
|
|
79547dec35 | ||
|
|
297c026c95 | ||
|
|
0c754bda60 | ||
|
|
33245a423a | ||
|
|
bf07f0a8a9 | ||
|
|
adc42caae3 | ||
|
|
3d2aff6c66 | ||
|
|
a3f07e643e | ||
|
|
642f1207c7 | ||
|
|
b181fbd1e8 | ||
|
|
82e3791b03 | ||
|
|
2756af0b33 | ||
|
|
4a77d11bc4 | ||
|
|
8e3ef83c19 | ||
|
|
e071fdf4a1 | ||
|
|
3e7ebc4be0 | ||
|
|
fa48be17d1 | ||
|
|
7a3f713670 | ||
|
|
8aade0e55c | ||
|
|
115295acb6 | ||
|
|
2a17ea84ac | ||
|
|
8e321e6d72 | ||
|
|
c026ae59fe | ||
|
|
a0ff593241 | ||
|
|
dcf0052039 | ||
|
|
fd8dd11643 | ||
|
|
291b657218 | ||
|
|
cdedf3ed24 | ||
|
|
cb7609bb05 | ||
|
|
5a4fab8abd | ||
|
|
f5627afe27 | ||
|
|
ec391246a1 | ||
|
|
d45da0742d | ||
|
|
b1e11e7666 | ||
|
|
52bb982721 | ||
|
|
1a9136514d | ||
|
|
687f446f00 | ||
|
|
0948d7b078 | ||
|
|
72d3e6aefe | ||
|
|
f3c4959fa4 | ||
|
|
24617f9924 | ||
|
|
11b97d1aea | ||
|
|
c4c654bfc6 | ||
|
|
73edc51f1a | ||
|
|
4d370d3edc | ||
|
|
884817f05c | ||
|
|
ca9dbba65b | ||
|
|
1367bcb7d4 | ||
|
|
6e7be4c029 | ||
|
|
dd18ba6cd6 | ||
|
|
8cf19ad79b | ||
|
|
15692fe031 | ||
|
|
1d445f7064 | ||
|
|
8ab72b8bcc | ||
|
|
e72bfcf8be | ||
|
|
8610f66fe1 | ||
|
|
8d9f38e7c2 | ||
|
|
3fa5a379f3 | ||
|
|
c2298a09be | ||
|
|
6d53035936 | ||
|
|
9998a937d5 | ||
|
|
b69aeea360 | ||
|
|
02c4206d49 | ||
|
|
c7b052c424 | ||
|
|
dd4bcfa867 | ||
|
|
7899ffb43d | ||
|
|
93862abdb0 | ||
|
|
e14a9911fd | ||
|
|
1e6b8b1c20 | ||
|
|
00bc122611 | ||
|
|
756851350a | ||
|
|
cb5da6ce76 | ||
|
|
1bf792adce | ||
|
|
f7266e7b44 | ||
|
|
88acceb8ef | ||
|
|
3cff8caa84 | ||
|
|
14583ad9f6 | ||
|
|
b23eaf1713 | ||
|
|
557c34e1f6 | ||
|
|
d1bb843f67 | ||
|
|
174f178f5b | ||
|
|
ace445b2dd | ||
|
|
c0fa75f404 | ||
|
|
53b415b127 | ||
|
|
91e62b16e0 | ||
|
|
e8a7ba6f47 | ||
|
|
4437a671d2 | ||
|
|
77080367bb | ||
|
|
d468b54226 | ||
|
|
29fe30e2f4 | ||
|
|
b2294a6c59 | ||
|
|
3df04b8428 | ||
|
|
42bb91868f | ||
|
|
583e45f568 | ||
|
|
8a8deed134 | ||
|
|
386d33df6e | ||
|
|
2124b97347 | ||
|
|
8333a7ba1f | ||
|
|
32ffa63879 | ||
|
|
4b24ab9681 | ||
|
|
72a38dbf38 | ||
|
|
cb3ff72699 | ||
|
|
fe46d3d4bc | ||
|
|
4f79144c5a | ||
|
|
11d1d125dc | ||
|
|
7c64f7427a | ||
|
|
f1c15a1cbe | ||
|
|
6cf3f4a520 | ||
|
|
8297a5c935 | ||
|
|
dee18df711 | ||
|
|
d415c55187 | ||
|
|
f3bda28338 | ||
|
|
3bda7851a1 | ||
|
|
41b999ffeb | ||
|
|
13ad61e8b0 | ||
|
|
7e39aa550c | ||
|
|
852f14e5b4 | ||
|
|
75f571ecdb | ||
|
|
eafc472279 | ||
|
|
f8fade3c0a | ||
|
|
2a8f220462 | ||
|
|
44db612d01 | ||
|
|
9c9bc7d15b | ||
|
|
5f09af0162 | ||
|
|
4322ae517b | ||
|
|
f3a2b70219 | ||
|
|
1858fd5c60 | ||
|
|
3f3e3f05d9 | ||
|
|
f6bc0ea969 | ||
|
|
6fc893d3f4 | ||
|
|
ce6006094e | ||
|
|
ee12d421f0 | ||
|
|
4594177035 | ||
|
|
b06e0e8d13 | ||
|
|
7ad3e629c8 | ||
|
|
60505a74a9 | ||
|
|
246393d3d6 | ||
|
|
e6d3f2e3cb | ||
|
|
24a2557d8e | ||
|
|
90a925f88c | ||
|
|
56d51dd461 | ||
|
|
65cb75a6b7 | ||
|
|
c2f7bb4efc | ||
|
|
eb13ca620e | ||
|
|
14f0831b05 | ||
|
|
976998806c | ||
|
|
dda8deb6e4 | ||
|
|
bcabcb618c | ||
|
|
a26876cff9 | ||
|
|
b36498f215 | ||
|
|
4f1d70775f | ||
|
|
94c3da003d | ||
|
|
2d25585faf | ||
|
|
f63362b460 | ||
|
|
4d4828e7c8 | ||
|
|
a6ca3d4222 | ||
|
|
3b18e3e664 | ||
|
|
0fd051a498 | ||
|
|
402278ecfd | ||
|
|
db8cc2267a | ||
|
|
6a7ffad9b8 | ||
|
|
4eccbd9269 | ||
|
|
c5752fe582 | ||
|
|
5dffb5cb88 | ||
|
|
22c569dc41 | ||
|
|
c8f7a4fe3e | ||
|
|
704eb3f9fb | ||
|
|
bc736926c5 | ||
|
|
e318914d01 | ||
|
|
32355ded88 | ||
|
|
7198c19da8 | ||
|
|
acc426e723 | ||
|
|
662793d7c2 | ||
|
|
e704210685 | ||
|
|
33c8ff5e7e | ||
|
|
6cd2b516b1 | ||
|
|
f5e4427903 | ||
|
|
dc26a872fd | ||
|
|
e19927af48 | ||
|
|
af41036c18 | ||
|
|
44e91c4710 | ||
|
|
f6e3738c1e | ||
|
|
d5bce37ed2 | ||
|
|
d8b1716c0a | ||
|
|
daf711328b | ||
|
|
a727cf0118 | ||
|
|
86d6ae4dec | ||
|
|
702c774d31 | ||
|
|
ef6d024a4a | ||
|
|
15e819385f | ||
|
|
60f71c0c8a | ||
|
|
50b4dc33fd | ||
|
|
12684f483d | ||
|
|
c7f58079b9 | ||
|
|
603fb0e03a | ||
|
|
63e72abb27 | ||
|
|
40cfe78aa7 | ||
|
|
1151928b3e | ||
|
|
10a69947dc | ||
|
|
2f8197eb97 | ||
|
|
82c3a49f4c | ||
|
|
661b944e0c | ||
|
|
d61719dcaf | ||
|
|
25b71efab0 | ||
|
|
26bc278b34 | ||
|
|
ad23a1167f | ||
|
|
dc283bed9a | ||
|
|
da7bf37c07 | ||
|
|
38ac46460e | ||
|
|
f11aba3492 | ||
|
|
376b9504f8 | ||
|
|
584c4c31a3 | ||
|
|
ca163cd2bb | ||
|
|
7587b2ad6a | ||
|
|
c83c68b4ee | ||
|
|
49cb8133ed | ||
|
|
7ba90791ea | ||
|
|
dede876f0b | ||
|
|
256f3232e4 | ||
|
|
c28b22fb59 | ||
|
|
7640762462 | ||
|
|
891c68ba42 | ||
|
|
7aa9840414 | ||
|
|
09e4b81d65 | ||
|
|
80c1cf4475 | ||
|
|
26d30481b5 | ||
|
|
4d32422fef | ||
|
|
78971f4ed7 | ||
|
|
c89c19deb2 | ||
|
|
43a655a57a | ||
|
|
93900b872e | ||
|
|
ac4be7031f | ||
|
|
102ac40226 | ||
|
|
7f90154410 | ||
|
|
013def197a | ||
|
|
baaff2725b | ||
|
|
2308b4ddc1 | ||
|
|
8e7588f372 | ||
|
|
8507097a9a | ||
|
|
cc7b38a141 | ||
|
|
dc05b1125b | ||
|
|
ed5f0e8393 | ||
|
|
36bca5908b | ||
|
|
cbc5b9d953 | ||
|
|
e61fdbf84f | ||
|
|
e67d5f1617 | ||
|
|
991cf732e5 | ||
|
|
8918675393 | ||
|
|
e0c5740a47 | ||
|
|
9ff1fa1bda | ||
|
|
7b06c90040 | ||
|
|
f6a3dc2f24 | ||
|
|
9bdd0cb9fc | ||
|
|
6f7e94d095 | ||
|
|
8c67f883c9 | ||
|
|
9d3285d0df | ||
|
|
b5fb747271 | ||
|
|
49efa18f97 | ||
|
|
1e6efe37a0 | ||
|
|
abef0c0303 | ||
|
|
bd6d1e323e | ||
|
|
9db1696d5a | ||
|
|
c6cb14c651 | ||
|
|
57b77eb212 | ||
|
|
8d843c90e9 | ||
|
|
9c3f5feda1 | ||
|
|
e4f78a1227 | ||
|
|
983bf5c610 | ||
|
|
78d2d84f20 | ||
|
|
a4537be773 | ||
|
|
8f62859971 | ||
|
|
0504fcd41d | ||
|
|
0b64549230 | ||
|
|
f12ca3ed27 | ||
|
|
2a672889bf | ||
|
|
9c930de186 | ||
|
|
bee242afa9 | ||
|
|
e2dadebda1 | ||
|
|
1830852901 | ||
|
|
5d73cd4aec | ||
|
|
b5b128264d | ||
|
|
08810d4b16 | ||
|
|
fb1517807c | ||
|
|
685b37bc43 | ||
|
|
5867d467f3 | ||
|
|
6b8bb97922 | ||
|
|
a61ad04ebe | ||
|
|
91c453d33c | ||
|
|
22defbfc7d | ||
|
|
0284a7e7da | ||
|
|
8e0d4ff337 | ||
|
|
ca7c9ec58e | ||
|
|
dd2a9f611d | ||
|
|
30e6e64d4f | ||
|
|
b68105e9f0 | ||
|
|
7a45ac2de3 | ||
|
|
085cbf7e68 | ||
|
|
f9373f8777 | ||
|
|
cad825ae7e | ||
|
|
d99006bb3c | ||
|
|
4915d4044c | ||
|
|
90c4a720e3 | ||
|
|
36071e6bd3 | ||
|
|
5b4366fa82 | ||
|
|
1df63be887 | ||
|
|
715f4195b2 | ||
|
|
b599d1cf26 | ||
|
|
023350d7e1 | ||
|
|
c6fde98a80 | ||
|
|
53c27f89dc | ||
|
|
5ec5e11dae | ||
|
|
fb6c9bf22e | ||
|
|
68348e0558 | ||
|
|
da8a480eff | ||
|
|
ae75508eff | ||
|
|
fe9afd90c4 | ||
|
|
1c4484266d | ||
|
|
34041b87c6 | ||
|
|
1919e62646 | ||
|
|
38d445a379 | ||
|
|
150494bce1 | ||
|
|
2424545532 | ||
|
|
e9c5a12702 | ||
|
|
3dcf6cd661 | ||
|
|
d91599d354 | ||
|
|
97bf9e24e5 | ||
|
|
cc2983a398 | ||
|
|
fa4e8fd087 | ||
|
|
9a1642b53c | ||
|
|
1b592317a1 | ||
|
|
c90e365d1f | ||
|
|
b4e34d0902 | ||
|
|
337cb188c3 | ||
|
|
f1d8b299db | ||
|
|
3a49648f0d | ||
|
|
19c46a86d7 | ||
|
|
6644b62f63 | ||
|
|
9ae3a54e8b | ||
|
|
cc66939095 | ||
|
|
de72e18a77 | ||
|
|
56da2ac880 | ||
|
|
4f38d0e2e7 | ||
|
|
ab5767fe94 | ||
|
|
ce6af76cce | ||
|
|
2630e4f016 | ||
|
|
7ae43310f4 | ||
|
|
5e60feab84 | ||
|
|
9934bd2628 | ||
|
|
9cc49da019 | ||
|
|
25b5eaddc5 | ||
|
|
6c44beb46d | ||
|
|
a3d26a2278 | ||
|
|
bfbeb97203 | ||
|
|
a2b03419d6 | ||
|
|
966e9799f2 | ||
|
|
cecffbc89f | ||
|
|
ae3b93a3c1 | ||
|
|
f0f34c3416 | ||
|
|
64d1a86fe5 | ||
|
|
86305cf261 | ||
|
|
459ea9c6db | ||
|
|
d17c31398c | ||
|
|
cbb61d65e4 | ||
|
|
6dc123615b | ||
|
|
56d841aa64 | ||
|
|
8899d8bfe1 | ||
|
|
ac09cc24a8 | ||
|
|
636983b697 | ||
|
|
986301d152 | ||
|
|
cd40abc359 | ||
|
|
ea8c29ecbd | ||
|
|
eecd93bf1a | ||
|
|
d1fed2fb45 | ||
|
|
5b41cbfbe8 | ||
|
|
17a72753bb | ||
|
|
e7b69dae91 | ||
|
|
d10acbc895 | ||
|
|
91b4200179 | ||
|
|
45cc2ba36e | ||
|
|
588d8f629d | ||
|
|
686c9456f4 | ||
|
|
333d03a51e | ||
|
|
14823809d3 | ||
|
|
1bef36fb20 | ||
|
|
c820685241 | ||
|
|
f59323455b | ||
|
|
20cf0a4a7f | ||
|
|
287ad44ffd | ||
|
|
ac30502b67 | ||
|
|
580fab6c78 | ||
|
|
84a3bbe84f | ||
|
|
1309654b4a | ||
|
|
2f7541040f | ||
|
|
3cc020a31b | ||
|
|
4baf7ccf7e | ||
|
|
674e9dba98 | ||
|
|
b7c5960d9a | ||
|
|
220a670b46 | ||
|
|
3c39f84290 | ||
|
|
bdab88c2e8 | ||
|
|
7cdf562efb | ||
|
|
ebfb842dc3 | ||
|
|
2c8fce3df6 | ||
|
|
b88fd353a2 | ||
|
|
55603990b9 | ||
|
|
976c6b46b0 | ||
|
|
8a94fdc66d | ||
|
|
4763897272 | ||
|
|
9946dcfd08 | ||
|
|
1262dafba8 | ||
|
|
f439757fe5 | ||
|
|
315259f5ec | ||
|
|
f8e65cc4c4 | ||
|
|
abf0f7fbf8 | ||
|
|
d13f5ed900 | ||
|
|
3fbfb5233e | ||
|
|
44a446b309 | ||
|
|
039988b506 | ||
|
|
0646f6fd96 | ||
|
|
cce714f873 | ||
|
|
eed9c3e597 | ||
|
|
71058685ef | ||
|
|
f80c3cb3f1 | ||
|
|
49b2d6d2c8 | ||
|
|
dcb94acee4 | ||
|
|
4b14dfa47e | ||
|
|
c2a6db38a8 | ||
|
|
0fe32d2842 | ||
|
|
fcfe8da765 | ||
|
|
b71dd65898 | ||
|
|
46158209f1 | ||
|
|
2302b0967b | ||
|
|
d62f8c6bfd | ||
|
|
0251f77331 | ||
|
|
7244201810 | ||
|
|
812ca1ba7d | ||
|
|
9bf0ff9848 | ||
|
|
f8605b7583 | ||
|
|
e7bb511821 | ||
|
|
bce139f9d1 | ||
|
|
c5050ae022 | ||
|
|
44d2dc7b43 | ||
|
|
7d81724e14 | ||
|
|
29cd269e08 | ||
|
|
b2b1b5e3a1 | ||
|
|
9d8a9fb378 | ||
|
|
d88c36f6ca | ||
|
|
1dac6166ab | ||
|
|
513a9359fd | ||
|
|
9f66c004a2 | ||
|
|
b5476c75fa | ||
|
|
11e030b9de | ||
|
|
c5d717c85a | ||
|
|
e73ec8e24c | ||
|
|
b577c154ee | ||
|
|
ec129b8262 | ||
|
|
698c89fd3b | ||
|
|
7bbccef81e | ||
|
|
b45799d319 | ||
|
|
17d00f2db7 | ||
|
|
58a28dd81e | ||
|
|
42b4aa2489 | ||
|
|
0beeb7a68b | ||
|
|
34b8c47415 | ||
|
|
c25f72febe | ||
|
|
f0c8e73446 | ||
|
|
157ab50f9b | ||
|
|
9eb576f8f3 | ||
|
|
f6da553aeb | ||
|
|
cf0c21eff3 | ||
|
|
99ad69e8cf | ||
|
|
452902bd9a | ||
|
|
e1fb203e46 | ||
|
|
ac2c28ad2c | ||
|
|
8481e588b2 | ||
|
|
d9944edf5e | ||
|
|
037421d762 | ||
|
|
3fe9a49ebc | ||
|
|
805003d811 | ||
|
|
bc88a34390 | ||
|
|
ef859d5ec3 | ||
|
|
55a95a2346 | ||
|
|
c81f552ed0 | ||
|
|
d2c7be9a32 | ||
|
|
bf6c580877 | ||
|
|
3dc263f9fb | ||
|
|
05e5277ae5 | ||
|
|
c2ba648ebe | ||
|
|
2bfa84d5e0 | ||
|
|
d6162af020 | ||
|
|
c4b8ad65da | ||
|
|
a056d7e0dc | ||
|
|
87bda3bbb6 | ||
|
|
c1bd4b3403 | ||
|
|
f2420d9978 | ||
|
|
2901b22be6 | ||
|
|
7b2d9a3268 | ||
|
|
9dae948a7c | ||
|
|
66a1781298 | ||
|
|
504d34a3ee | ||
|
|
3d83d9d561 | ||
|
|
5c33852a65 | ||
|
|
a2740b6873 | ||
|
|
547942cac8 | ||
|
|
8a5f7a4cb8 | ||
|
|
d2a3720ec4 | ||
|
|
732f1e8ef7 | ||
|
|
e05b4e94dc | ||
|
|
05476b99c6 | ||
|
|
06f41f5792 | ||
|
|
fb8b41b445 | ||
|
|
eeb39e6e0f | ||
|
|
fda41ae8d5 | ||
|
|
3a7e450b55 | ||
|
|
7d781d570c | ||
|
|
e581aa0121 | ||
|
|
ef53fa0827 | ||
|
|
86517860ff | ||
|
|
d62fc29581 | ||
|
|
39e7a7d2db | ||
|
|
88f1d09cb8 | ||
|
|
2109cf2508 | ||
|
|
04a028245b | ||
|
|
f10ff18abc | ||
|
|
359a5c269c | ||
|
|
2a23cc9079 | ||
|
|
7fcbc467df | ||
|
|
f2d9c9fb8e | ||
|
|
2c05c800d9 | ||
|
|
89fa4e73a5 | ||
|
|
c4a574422b | ||
|
|
e7a9e3a5b6 | ||
|
|
cbb46cb70a | ||
|
|
37189ed67e | ||
|
|
ebd41b8a46 | ||
|
|
42ca256bac | ||
|
|
3d3b1fc902 |
@@ -0,0 +1,105 @@
|
||||
name: OCI validation report
|
||||
description: Report a real test of an application from the OCI manager catalog.
|
||||
title: "OCI validation: "
|
||||
labels: ["oci-validation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
One report describes one application and the scenario you tested. Once it is reviewed, the application is recorded as verified: it shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md](https://github.com/MacRimi/ProxMenux/blob/develop/oci/VALIDATION.md) with your GitHub user, the date and a link to this report.
|
||||
|
||||
Reports that an application does not work are just as useful: they tell others what to expect and what needs fixing.
|
||||
- type: input
|
||||
id: application
|
||||
attributes:
|
||||
label: Application
|
||||
description: Name or ID as it appears in the OCI catalog, for example "Glances" or "glances".
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Image version
|
||||
description: Version or tag shown by the installer.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: digest
|
||||
attributes:
|
||||
label: Image digest
|
||||
description: Shown in the App tab of the container in ProxMenux Monitor, after the build date — for example "2026-09-06 · b1f339cf". The full sha256 digest is also accepted.
|
||||
placeholder: b1f339cf
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: proxmox
|
||||
attributes:
|
||||
label: Proxmox VE version
|
||||
placeholder: "9.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install
|
||||
attributes:
|
||||
label: Install mode
|
||||
options:
|
||||
- Default configuration
|
||||
- Advanced configuration
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: storage
|
||||
attributes:
|
||||
label: Data storage
|
||||
options:
|
||||
- Volume on Proxmox storage
|
||||
- Host directory
|
||||
- Both
|
||||
- No persistent data
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: network
|
||||
attributes:
|
||||
label: Network
|
||||
options:
|
||||
- DHCP
|
||||
- Static address
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: gpu
|
||||
attributes:
|
||||
label: GPU
|
||||
options:
|
||||
- No GPU
|
||||
- Intel
|
||||
- AMD
|
||||
- NVIDIA
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: What was checked
|
||||
options:
|
||||
- label: Installs cleanly
|
||||
- label: The application responds (web interface or service)
|
||||
- label: Keeps working after a container restart
|
||||
- label: Survives a Proxmox backup and restore
|
||||
- label: An image update keeps the data
|
||||
- type: dropdown
|
||||
id: result
|
||||
attributes:
|
||||
label: Result
|
||||
options:
|
||||
- Works
|
||||
- Works with problems
|
||||
- Does not work
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
description: Anything worth knowing. If something failed, what you saw and how to reproduce it.
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "proxmenux-offline-node-tests",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxmenux-offline-node-tests",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "0.454.0",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.454.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
|
||||
"integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "proxmenux-offline-node-tests",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Only the dependencies of the qualified tests/*.cjs CI lane",
|
||||
"dependencies": {
|
||||
"typescript": "5.9.3",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"lucide-react": "0.454.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# GitHub users who can validate an OCI validation report by commenting
|
||||
# /validated on it. A reviewer does not validate their own report.
|
||||
MacRimi
|
||||
Vaso73
|
||||
f3rs3n
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"repository": "MacRimi/ProxMenux",
|
||||
"updatedAt": "2026-09-01T09:26:29.128Z",
|
||||
"updatedAt": "2026-11-02T04:23:29.007Z",
|
||||
"backfilledAt": "2026-07-22T18:09:20.834Z",
|
||||
"totals": {
|
||||
"downloads": 0,
|
||||
"clones": 21669
|
||||
"clones": 68648
|
||||
},
|
||||
"assets": {},
|
||||
"traffic": {
|
||||
@@ -129,6 +129,250 @@
|
||||
"2026-08-31": {
|
||||
"count": 343,
|
||||
"uniques": 268
|
||||
},
|
||||
"2026-09-01": {
|
||||
"count": 552,
|
||||
"uniques": 396
|
||||
},
|
||||
"2026-09-02": {
|
||||
"count": 1065,
|
||||
"uniques": 726
|
||||
},
|
||||
"2026-09-03": {
|
||||
"count": 1489,
|
||||
"uniques": 770
|
||||
},
|
||||
"2026-09-04": {
|
||||
"count": 938,
|
||||
"uniques": 642
|
||||
},
|
||||
"2026-09-05": {
|
||||
"count": 722,
|
||||
"uniques": 513
|
||||
},
|
||||
"2026-09-06": {
|
||||
"count": 884,
|
||||
"uniques": 445
|
||||
},
|
||||
"2026-09-07": {
|
||||
"count": 655,
|
||||
"uniques": 485
|
||||
},
|
||||
"2026-09-08": {
|
||||
"count": 597,
|
||||
"uniques": 440
|
||||
},
|
||||
"2026-09-09": {
|
||||
"count": 615,
|
||||
"uniques": 431
|
||||
},
|
||||
"2026-09-10": {
|
||||
"count": 542,
|
||||
"uniques": 383
|
||||
},
|
||||
"2026-09-11": {
|
||||
"count": 646,
|
||||
"uniques": 342
|
||||
},
|
||||
"2026-09-12": {
|
||||
"count": 451,
|
||||
"uniques": 336
|
||||
},
|
||||
"2026-09-13": {
|
||||
"count": 482,
|
||||
"uniques": 362
|
||||
},
|
||||
"2026-09-14": {
|
||||
"count": 610,
|
||||
"uniques": 349
|
||||
},
|
||||
"2026-09-15": {
|
||||
"count": 452,
|
||||
"uniques": 343
|
||||
},
|
||||
"2026-09-16": {
|
||||
"count": 651,
|
||||
"uniques": 335
|
||||
},
|
||||
"2026-09-17": {
|
||||
"count": 881,
|
||||
"uniques": 316
|
||||
},
|
||||
"2026-09-18": {
|
||||
"count": 414,
|
||||
"uniques": 295
|
||||
},
|
||||
"2026-09-19": {
|
||||
"count": 701,
|
||||
"uniques": 300
|
||||
},
|
||||
"2026-09-20": {
|
||||
"count": 405,
|
||||
"uniques": 271
|
||||
},
|
||||
"2026-09-21": {
|
||||
"count": 477,
|
||||
"uniques": 303
|
||||
},
|
||||
"2026-09-22": {
|
||||
"count": 587,
|
||||
"uniques": 347
|
||||
},
|
||||
"2026-09-23": {
|
||||
"count": 436,
|
||||
"uniques": 293
|
||||
},
|
||||
"2026-09-24": {
|
||||
"count": 377,
|
||||
"uniques": 276
|
||||
},
|
||||
"2026-09-25": {
|
||||
"count": 526,
|
||||
"uniques": 350
|
||||
},
|
||||
"2026-09-26": {
|
||||
"count": 723,
|
||||
"uniques": 397
|
||||
},
|
||||
"2026-09-27": {
|
||||
"count": 994,
|
||||
"uniques": 620
|
||||
},
|
||||
"2026-09-28": {
|
||||
"count": 1548,
|
||||
"uniques": 1091
|
||||
},
|
||||
"2026-09-29": {
|
||||
"count": 1043,
|
||||
"uniques": 692
|
||||
},
|
||||
"2026-09-30": {
|
||||
"count": 768,
|
||||
"uniques": 504
|
||||
},
|
||||
"2026-10-01": {
|
||||
"count": 661,
|
||||
"uniques": 443
|
||||
},
|
||||
"2026-10-02": {
|
||||
"count": 596,
|
||||
"uniques": 425
|
||||
},
|
||||
"2026-10-03": {
|
||||
"count": 486,
|
||||
"uniques": 372
|
||||
},
|
||||
"2026-10-04": {
|
||||
"count": 1074,
|
||||
"uniques": 333
|
||||
},
|
||||
"2026-10-05": {
|
||||
"count": 1183,
|
||||
"uniques": 366
|
||||
},
|
||||
"2026-10-06": {
|
||||
"count": 648,
|
||||
"uniques": 367
|
||||
},
|
||||
"2026-10-07": {
|
||||
"count": 527,
|
||||
"uniques": 381
|
||||
},
|
||||
"2026-10-08": {
|
||||
"count": 540,
|
||||
"uniques": 352
|
||||
},
|
||||
"2026-10-09": {
|
||||
"count": 548,
|
||||
"uniques": 363
|
||||
},
|
||||
"2026-10-10": {
|
||||
"count": 443,
|
||||
"uniques": 305
|
||||
},
|
||||
"2026-10-11": {
|
||||
"count": 511,
|
||||
"uniques": 305
|
||||
},
|
||||
"2026-10-12": {
|
||||
"count": 520,
|
||||
"uniques": 362
|
||||
},
|
||||
"2026-10-13": {
|
||||
"count": 564,
|
||||
"uniques": 354
|
||||
},
|
||||
"2026-10-14": {
|
||||
"count": 540,
|
||||
"uniques": 372
|
||||
},
|
||||
"2026-10-15": {
|
||||
"count": 1253,
|
||||
"uniques": 429
|
||||
},
|
||||
"2026-10-16": {
|
||||
"count": 751,
|
||||
"uniques": 342
|
||||
},
|
||||
"2026-10-17": {
|
||||
"count": 821,
|
||||
"uniques": 380
|
||||
},
|
||||
"2026-10-18": {
|
||||
"count": 727,
|
||||
"uniques": 348
|
||||
},
|
||||
"2026-10-19": {
|
||||
"count": 712,
|
||||
"uniques": 368
|
||||
},
|
||||
"2026-10-20": {
|
||||
"count": 908,
|
||||
"uniques": 464
|
||||
},
|
||||
"2026-10-21": {
|
||||
"count": 623,
|
||||
"uniques": 406
|
||||
},
|
||||
"2026-10-22": {
|
||||
"count": 755,
|
||||
"uniques": 419
|
||||
},
|
||||
"2026-10-23": {
|
||||
"count": 601,
|
||||
"uniques": 337
|
||||
},
|
||||
"2026-10-24": {
|
||||
"count": 900,
|
||||
"uniques": 316
|
||||
},
|
||||
"2026-10-25": {
|
||||
"count": 1562,
|
||||
"uniques": 357
|
||||
},
|
||||
"2026-10-26": {
|
||||
"count": 928,
|
||||
"uniques": 397
|
||||
},
|
||||
"2026-10-27": {
|
||||
"count": 1876,
|
||||
"uniques": 1119
|
||||
},
|
||||
"2026-10-28": {
|
||||
"count": 1304,
|
||||
"uniques": 737
|
||||
},
|
||||
"2026-10-29": {
|
||||
"count": 1349,
|
||||
"uniques": 650
|
||||
},
|
||||
"2026-10-30": {
|
||||
"count": 1084,
|
||||
"uniques": 580
|
||||
},
|
||||
"2026-10-31": {
|
||||
"count": 753,
|
||||
"uniques": 475
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3358,7 +3602,441 @@
|
||||
"stars": 2925,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 21669
|
||||
"clones": 22221
|
||||
},
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"stars": 2926,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 23286
|
||||
},
|
||||
{
|
||||
"date": "2026-09-03",
|
||||
"stars": 2927,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 24775
|
||||
},
|
||||
{
|
||||
"date": "2026-09-04",
|
||||
"stars": 2930,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 25713
|
||||
},
|
||||
{
|
||||
"date": "2026-09-05",
|
||||
"stars": 2944,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 26435
|
||||
},
|
||||
{
|
||||
"date": "2026-09-06",
|
||||
"stars": 2954,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 27319
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"stars": 2959,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 27974
|
||||
},
|
||||
{
|
||||
"date": "2026-09-08",
|
||||
"stars": 2959,
|
||||
"forks": 153,
|
||||
"downloads": 0,
|
||||
"clones": 28571
|
||||
},
|
||||
{
|
||||
"date": "2026-09-09",
|
||||
"stars": 2960,
|
||||
"forks": 153,
|
||||
"downloads": 0,
|
||||
"clones": 29186
|
||||
},
|
||||
{
|
||||
"date": "2026-09-10",
|
||||
"stars": 2961,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 29728
|
||||
},
|
||||
{
|
||||
"date": "2026-09-11",
|
||||
"stars": 2965,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 30374
|
||||
},
|
||||
{
|
||||
"date": "2026-09-12",
|
||||
"stars": 2967,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 30825
|
||||
},
|
||||
{
|
||||
"date": "2026-09-13",
|
||||
"stars": 2971,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 31307
|
||||
},
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"stars": 2972,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 31917
|
||||
},
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"stars": 2974,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 32369
|
||||
},
|
||||
{
|
||||
"date": "2026-09-16",
|
||||
"stars": 2974,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 33020
|
||||
},
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"stars": 2975,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 33901
|
||||
},
|
||||
{
|
||||
"date": "2026-09-18",
|
||||
"stars": 2978,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 34315
|
||||
},
|
||||
{
|
||||
"date": "2026-09-19",
|
||||
"stars": 2981,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 35016
|
||||
},
|
||||
{
|
||||
"date": "2026-09-20",
|
||||
"stars": 2981,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 35421
|
||||
},
|
||||
{
|
||||
"date": "2026-09-21",
|
||||
"stars": 2984,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 35898
|
||||
},
|
||||
{
|
||||
"date": "2026-09-22",
|
||||
"stars": 2986,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 36485
|
||||
},
|
||||
{
|
||||
"date": "2026-09-23",
|
||||
"stars": 2989,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 36921
|
||||
},
|
||||
{
|
||||
"date": "2026-09-24",
|
||||
"stars": 2991,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 37298
|
||||
},
|
||||
{
|
||||
"date": "2026-09-25",
|
||||
"stars": 2993,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 37824
|
||||
},
|
||||
{
|
||||
"date": "2026-09-26",
|
||||
"stars": 2996,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 38547
|
||||
},
|
||||
{
|
||||
"date": "2026-09-27",
|
||||
"stars": 3007,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 39541
|
||||
},
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"stars": 3041,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 41089
|
||||
},
|
||||
{
|
||||
"date": "2026-09-29",
|
||||
"stars": 3052,
|
||||
"forks": 157,
|
||||
"downloads": 0,
|
||||
"clones": 42132
|
||||
},
|
||||
{
|
||||
"date": "2026-09-30",
|
||||
"stars": 3062,
|
||||
"forks": 157,
|
||||
"downloads": 0,
|
||||
"clones": 42900
|
||||
},
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"stars": 3067,
|
||||
"forks": 158,
|
||||
"downloads": 0,
|
||||
"clones": 43561
|
||||
},
|
||||
{
|
||||
"date": "2026-10-02",
|
||||
"stars": 3072,
|
||||
"forks": 159,
|
||||
"downloads": 0,
|
||||
"clones": 44157
|
||||
},
|
||||
{
|
||||
"date": "2026-10-03",
|
||||
"stars": 3077,
|
||||
"forks": 159,
|
||||
"downloads": 0,
|
||||
"clones": 44643
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"stars": 3082,
|
||||
"forks": 160,
|
||||
"downloads": 0,
|
||||
"clones": 45717
|
||||
},
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"stars": 3087,
|
||||
"forks": 160,
|
||||
"downloads": 0,
|
||||
"clones": 46900
|
||||
},
|
||||
{
|
||||
"date": "2026-10-06",
|
||||
"stars": 3090,
|
||||
"forks": 161,
|
||||
"downloads": 0,
|
||||
"clones": 47548
|
||||
},
|
||||
{
|
||||
"date": "2026-10-07",
|
||||
"stars": 3103,
|
||||
"forks": 163,
|
||||
"downloads": 0,
|
||||
"clones": 48075
|
||||
},
|
||||
{
|
||||
"date": "2026-10-08",
|
||||
"stars": 3112,
|
||||
"forks": 163,
|
||||
"downloads": 0,
|
||||
"clones": 48615
|
||||
},
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"stars": 3123,
|
||||
"forks": 165,
|
||||
"downloads": 0,
|
||||
"clones": 49163
|
||||
},
|
||||
{
|
||||
"date": "2026-10-10",
|
||||
"stars": 3128,
|
||||
"forks": 164,
|
||||
"downloads": 0,
|
||||
"clones": 49606
|
||||
},
|
||||
{
|
||||
"date": "2026-10-11",
|
||||
"stars": 3134,
|
||||
"forks": 165,
|
||||
"downloads": 0,
|
||||
"clones": 50117
|
||||
},
|
||||
{
|
||||
"date": "2026-10-12",
|
||||
"stars": 3138,
|
||||
"forks": 168,
|
||||
"downloads": 0,
|
||||
"clones": 50637
|
||||
},
|
||||
{
|
||||
"date": "2026-10-13",
|
||||
"stars": 3141,
|
||||
"forks": 169,
|
||||
"downloads": 0,
|
||||
"clones": 51201
|
||||
},
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"stars": 3148,
|
||||
"forks": 170,
|
||||
"downloads": 0,
|
||||
"clones": 51741
|
||||
},
|
||||
{
|
||||
"date": "2026-10-15",
|
||||
"stars": 3160,
|
||||
"forks": 171,
|
||||
"downloads": 0,
|
||||
"clones": 52994
|
||||
},
|
||||
{
|
||||
"date": "2026-10-16",
|
||||
"stars": 3166,
|
||||
"forks": 173,
|
||||
"downloads": 0,
|
||||
"clones": 53745
|
||||
},
|
||||
{
|
||||
"date": "2026-10-17",
|
||||
"stars": 3170,
|
||||
"forks": 173,
|
||||
"downloads": 0,
|
||||
"clones": 54566
|
||||
},
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"stars": 3174,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 55293
|
||||
},
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"stars": 3180,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 56005
|
||||
},
|
||||
{
|
||||
"date": "2026-10-20",
|
||||
"stars": 3191,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 56913
|
||||
},
|
||||
{
|
||||
"date": "2026-10-21",
|
||||
"stars": 3205,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 57536
|
||||
},
|
||||
{
|
||||
"date": "2026-10-22",
|
||||
"stars": 3214,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 58291
|
||||
},
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"stars": 3220,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 58892
|
||||
},
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"stars": 3226,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 59792
|
||||
},
|
||||
{
|
||||
"date": "2026-10-25",
|
||||
"stars": 3229,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 61354
|
||||
},
|
||||
{
|
||||
"date": "2026-10-26",
|
||||
"stars": 3234,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 62282
|
||||
},
|
||||
{
|
||||
"date": "2026-10-27",
|
||||
"stars": 3237,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 64158
|
||||
},
|
||||
{
|
||||
"date": "2026-10-28",
|
||||
"stars": 3241,
|
||||
"forks": 176,
|
||||
"downloads": 0,
|
||||
"clones": 65462
|
||||
},
|
||||
{
|
||||
"date": "2026-10-29",
|
||||
"stars": 3243,
|
||||
"forks": 176,
|
||||
"downloads": 0,
|
||||
"clones": 66811
|
||||
},
|
||||
{
|
||||
"date": "2026-10-30",
|
||||
"stars": 3247,
|
||||
"forks": 177,
|
||||
"downloads": 0,
|
||||
"clones": 67895
|
||||
},
|
||||
{
|
||||
"date": "2026-10-31",
|
||||
"stars": 3251,
|
||||
"forks": 177,
|
||||
"downloads": 0,
|
||||
"clones": 68648
|
||||
},
|
||||
{
|
||||
"date": "2026-11-01",
|
||||
"stars": 3254,
|
||||
"forks": 177,
|
||||
"downloads": 0,
|
||||
"clones": 68648
|
||||
},
|
||||
{
|
||||
"date": "2026-11-02",
|
||||
"stars": 3258,
|
||||
"forks": 178,
|
||||
"downloads": 0,
|
||||
"clones": 68648
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||
against the English source (AppImage/messages/en/common.json).
|
||||
|
||||
Guardrails:
|
||||
- Non-empty string translations are PRESERVED, including values equal
|
||||
to English. --refresh explicitly opts into replacing valid strings.
|
||||
- Arrays, objects and non-string leaves retain their JSON types. Array
|
||||
entries are translated individually, never as serialized Python text.
|
||||
- Preflight all locales; skip locales with existing type mismatches without
|
||||
provider calls or writes, including with --refresh. Valid locales proceed,
|
||||
but any skipped locale makes the run exit 1 (partial failure).
|
||||
The diagnostic identifies the locale
|
||||
and JSON Pointer requiring manual reconciliation. No automatic migration
|
||||
of serialized arrays is attempted: parseability or equal length cannot
|
||||
prove correspondence to current English indices. Recover human text only
|
||||
after checking its original source/order; do not delete it to force refill.
|
||||
Null string leaves remain eligible for filling, as in earlier versions.
|
||||
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
|
||||
are extracted before translation and restored afterwards, so the
|
||||
interpolation contract stays intact regardless of what the
|
||||
translation provider does with the surrounding text.
|
||||
- `sk` IS translated by default too. Guardrail #1 protects every key
|
||||
Vaso73 has curated by hand; auto-translation only fills the keys
|
||||
that are still on the English fallback in sk.
|
||||
|
||||
Workflow limitation: build-i18n-messages.yml runs Commit + push only after
|
||||
successful generation. Exit 1 for skipped locales prevents that step, even
|
||||
when valid locales made local progress. This does not restore automatic
|
||||
publication for the default locale set until mismatches are reconciled or
|
||||
workflow policy is separately changed. Invalid JSON syntax still aborts
|
||||
preflight globally; per-locale skipping covers parsed catalog type mismatches.
|
||||
|
||||
Reuses the same translation providers as build_translation_cache.py so
|
||||
the CI environment (googletrans pinning, AppImage provider) stays
|
||||
identical.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Reuse providers + cleaner from the CLI translation script.
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from build_translation_cache import ( # noqa: E402
|
||||
clean_translation,
|
||||
protect_technical_terms,
|
||||
restore_technical_terms,
|
||||
translate_appimage,
|
||||
translate_google_web,
|
||||
translate_googletrans,
|
||||
)
|
||||
|
||||
|
||||
# sk IS included in the default. Guardrail #1 (never overwrite a key
|
||||
# whose target value differs from EN) protects every string Vaso73 has
|
||||
# already curated by hand — auto-translation only ever touches keys
|
||||
# that are still on the English fallback in sk. Trade-off accepted:
|
||||
# users on sk see a decent auto-translation for new keys instead of raw
|
||||
# English while the human maintainer catches up, and Vaso73 keeps full
|
||||
# ownership of the wording via follow-up PRs.
|
||||
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
|
||||
DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard. Translate:"
|
||||
|
||||
# next-intl / ICU-style placeholders: {name}, {vmid}, {count}, {app_name}.
|
||||
# We deliberately do NOT match `{{ escaped }}` or nested braces — the
|
||||
# codebase uses only the simple form.
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
||||
|
||||
|
||||
CatalogPath = tuple[str | int, ...]
|
||||
|
||||
|
||||
def flatten(node: object, prefix: CatalogPath = ()) -> dict[CatalogPath, object]:
|
||||
"""Keep typed path segments and empty container markers, without coercion."""
|
||||
if isinstance(node, dict):
|
||||
out = {prefix: {}}
|
||||
children = node.items()
|
||||
elif isinstance(node, list):
|
||||
out = {prefix: []}
|
||||
children = enumerate(node)
|
||||
else:
|
||||
return {prefix: node}
|
||||
for key, value in children:
|
||||
out.update(flatten(value, prefix + (key,)))
|
||||
return out
|
||||
|
||||
|
||||
def unflatten(flat: dict[CatalogPath, object]) -> object:
|
||||
"""Rebuild a tree from typed paths; parents precede children by depth."""
|
||||
nodes = {}
|
||||
for path in sorted(flat, key=len):
|
||||
value = flat[path]
|
||||
nodes[path] = {} if isinstance(value, dict) else [] if isinstance(value, list) else value
|
||||
if path:
|
||||
parent = nodes[path[:-1]]
|
||||
key = path[-1]
|
||||
if isinstance(parent, list):
|
||||
while len(parent) <= key:
|
||||
parent.append(None)
|
||||
parent[key] = nodes[path]
|
||||
return nodes[()]
|
||||
|
||||
|
||||
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
||||
"""Swap each ``{xxx}`` for an opaque token that machine translators
|
||||
tend to leave alone. Order is preserved so restore_placeholders can
|
||||
walk it linearly."""
|
||||
placeholders: list[str] = []
|
||||
|
||||
def _swap(match: re.Match) -> str:
|
||||
placeholders.append(match.group(0))
|
||||
# No underscores: a local model splits on them and returns
|
||||
# "PMX PH 0", so the placeholder never comes back.
|
||||
return f"PMXPH{len(placeholders) - 1:03d}"
|
||||
|
||||
return PLACEHOLDER_RE.sub(_swap, text), placeholders
|
||||
|
||||
|
||||
def restore_placeholders(text: str, placeholders: list[str]) -> str:
|
||||
"""Reverse of ``protect_placeholders``. If the provider mangled a
|
||||
token beyond recognition we leave the mangled form in place — the
|
||||
fallback assignment (target = existing or EN) upstream catches
|
||||
the worst case."""
|
||||
for i, original in enumerate(placeholders):
|
||||
text = text.replace(f"PMXPH{i:03d}", original)
|
||||
return text
|
||||
|
||||
|
||||
def translate_argos(text: str, lang: str) -> str:
|
||||
"""LibreTranslate's engine, running locally.
|
||||
|
||||
The remote providers answer a few thousand strings and then start
|
||||
refusing, and a refusal here hands back the English and stores it as
|
||||
a translation. Local models have no quota and no silent failure mode,
|
||||
which is what makes them the right choice for filling a catalogue by
|
||||
hand before it is committed.
|
||||
"""
|
||||
try:
|
||||
import argostranslate.translate as argos # type: ignore
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
"argostranslate is not installed. Install argostranslate and the "
|
||||
"en->target packages, or run with another provider."
|
||||
) from exc
|
||||
return argos.translate(text, "en", lang)
|
||||
|
||||
|
||||
def translate_one(
|
||||
text: str,
|
||||
lang: str,
|
||||
provider: str,
|
||||
context: str,
|
||||
timeout: int,
|
||||
appimage_path: Path,
|
||||
) -> str:
|
||||
"""Dispatch to the correct provider. Reuses the same
|
||||
implementations as build_translation_cache.py so there is exactly
|
||||
one place to fix if a provider changes upstream."""
|
||||
if provider == "googletrans":
|
||||
raw = translate_googletrans(text, lang, context)
|
||||
elif provider == "google-web":
|
||||
raw = translate_google_web(text, lang, context, timeout)
|
||||
elif provider == "appimage":
|
||||
raw = translate_appimage(text, lang, context, timeout, appimage_path)
|
||||
elif provider == "argos":
|
||||
raw = translate_argos(text, lang)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
return clean_translation(raw) or text
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SystemExit(f"Invalid JSON at {path}: {exc}") from exc
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> None:
|
||||
"""Write with indent=2, no sort_keys — we want to keep the same
|
||||
top-level ordering the maintainer uses in en/common.json so diffs
|
||||
stay readable side-by-side."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=Path,
|
||||
default=Path("AppImage/messages/en/common.json"),
|
||||
help="Path to the English source catalog.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--messages-dir",
|
||||
type=Path,
|
||||
default=Path("AppImage/messages"),
|
||||
help="Directory that contains per-locale subdirectories.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--languages",
|
||||
default=",".join(DEFAULT_LANGUAGES),
|
||||
help=(
|
||||
"Comma-separated target locales. Includes sk by default; "
|
||||
"guardrail #1 never overwrites keys whose sk value differs "
|
||||
"from EN, so Vaso73's curated translations are safe."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("argos", "appimage", "googletrans", "google-web"),
|
||||
default="googletrans",
|
||||
help=(
|
||||
"Translation provider. The default matches build_translation_cache "
|
||||
"and is what CI uses; `argos` runs locally with no quota and is the "
|
||||
"one to reach for when filling a catalogue before committing it."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--appimage-path",
|
||||
type=Path,
|
||||
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
|
||||
)
|
||||
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||
parser.add_argument("--timeout", type=int, default=30)
|
||||
parser.add_argument("--sleep", type=float, default=0.15)
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Retranslate the keys named by --refresh-keys. On its own it has "
|
||||
"no effect: an existing translation is never replaced."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refresh-keys",
|
||||
type=Path,
|
||||
help=(
|
||||
"File with one dotted message key per line. Only these may be "
|
||||
"retranslated over an existing value, and only with --refresh."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Only translate the first N missing keys per locale (test runs).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-every",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Write the locale JSON every N translated keys so a crash mid-run leaves partial progress on disk.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_arg_parser().parse_args()
|
||||
|
||||
source = args.source.resolve()
|
||||
messages_dir = args.messages_dir.resolve()
|
||||
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
|
||||
|
||||
if not source.is_file():
|
||||
print(f"Source not found: {source}", file=sys.stderr)
|
||||
return 1
|
||||
if not languages:
|
||||
print("No destination languages selected.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
en_nested = read_json(source)
|
||||
en_flat = flatten(en_nested)
|
||||
print(f"Source: {source}", flush=True)
|
||||
print(f"EN keys: {len(en_flat)}", flush=True)
|
||||
print(f"Target locales: {', '.join(languages)}", flush=True)
|
||||
print(f"Provider: {args.provider}", flush=True)
|
||||
print(f"Sleep between calls: {args.sleep}s", flush=True)
|
||||
|
||||
refresh_keys: set[str] = set()
|
||||
if args.refresh_keys is not None:
|
||||
if not args.refresh:
|
||||
print("--refresh-keys needs --refresh.", file=sys.stderr)
|
||||
return 1
|
||||
refresh_keys = {
|
||||
line.strip()
|
||||
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
print(f"Refreshing {len(refresh_keys)} named keys.", flush=True)
|
||||
elif args.refresh:
|
||||
print("--refresh without --refresh-keys: existing translations are kept.",
|
||||
flush=True)
|
||||
|
||||
total_failures: list[tuple[str, CatalogPath, str]] = []
|
||||
|
||||
# Preflight every locale before any provider call or write. Serialized
|
||||
# arrays have no trustworthy index correspondence, even if JSON parses.
|
||||
# Never guess, evaluate Python repr, or discard human text on --refresh.
|
||||
targets = {}
|
||||
skipped = set()
|
||||
for lang in languages:
|
||||
locale_path = messages_dir / lang / "common.json"
|
||||
target_flat = flatten(read_json(locale_path))
|
||||
targets[lang] = target_flat
|
||||
for key, en_value in en_flat.items():
|
||||
if key not in target_flat:
|
||||
continue
|
||||
existing = target_flat[key]
|
||||
if isinstance(en_value, str) and existing is None:
|
||||
continue # Historical null string leaves are treated as empty.
|
||||
if type(existing) is not type(en_value):
|
||||
pointer = "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in key)
|
||||
location = f"JSON Pointer {json.dumps(pointer)}" + (" (root)" if not key else "")
|
||||
print(
|
||||
f"{locale_path}: {location}: expected {type(en_value).__name__}, "
|
||||
f"found {type(existing).__name__}; manual reconciliation required. "
|
||||
"Locale skipped; its catalog will not be written.", file=sys.stderr,
|
||||
)
|
||||
skipped.add(lang)
|
||||
|
||||
for lang in languages:
|
||||
if lang in skipped:
|
||||
continue
|
||||
locale_path = messages_dir / lang / "common.json"
|
||||
target_flat = targets[lang]
|
||||
|
||||
# Decide what needs translating. Same rule as
|
||||
# build_translation_cache.py: only touch keys whose target
|
||||
# value is empty. Never overwrite an existing value — that
|
||||
# covers three legitimate cases in one line:
|
||||
# 1. Human-curated translations (protected trivially).
|
||||
# 2. Universal tokens the maintainer left equal to EN on
|
||||
# purpose (Hardware, Terminal, Normal, SMART, OK, CPU %,
|
||||
# {count}h, product names, etc.). Around 120 keys in
|
||||
# es/common.json — the old `existing == en_value` rule
|
||||
# kept resending these to Google every run, and the
|
||||
# provider sometimes mangled them (`Terminal` →
|
||||
# `terminal`, `CPU %` → `% de CPU`, ...).
|
||||
# 3. Auto-fills from prior runs whose output happened to
|
||||
# match EN — leaving them alone is the intended
|
||||
# steady-state, not a bug.
|
||||
# An existing translation is never replaced. --refresh only reaches
|
||||
# the keys named in --refresh-keys, so an overwrite is always one
|
||||
# somebody asked for by name rather than a side effect of a run.
|
||||
# Seed structural nodes and non-text leaves before any checkpoint.
|
||||
# Only strings are sent to a translation provider.
|
||||
original_flat = target_flat.copy()
|
||||
for key, en_value in en_flat.items():
|
||||
if not isinstance(en_value, str) or not en_value:
|
||||
target_flat.setdefault(key, en_value)
|
||||
elif key and isinstance(key[-1], int):
|
||||
# Null keeps an untranslated array position addressable while
|
||||
# allowing the runtime English fallback and a later retry.
|
||||
target_flat.setdefault(key, None)
|
||||
missing: list[CatalogPath] = []
|
||||
for key, en_value in en_flat.items():
|
||||
if not isinstance(en_value, str) or not en_value:
|
||||
continue
|
||||
existing = target_flat.get(key, "")
|
||||
if not existing or ".".join(str(part) for part in key) in refresh_keys:
|
||||
missing.append(key)
|
||||
|
||||
if args.limit > 0:
|
||||
missing = missing[: args.limit]
|
||||
|
||||
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
|
||||
if not missing:
|
||||
if target_flat != original_flat:
|
||||
write_json(locale_path, unflatten(target_flat))
|
||||
print(f" {lang}: nothing to translate", flush=True)
|
||||
continue
|
||||
|
||||
failures_for_lang: list[tuple[str, CatalogPath, str]] = []
|
||||
|
||||
for index, key in enumerate(missing, start=1):
|
||||
en_value = en_flat[key]
|
||||
protected, placeholders = protect_placeholders(en_value)
|
||||
protected, technical_terms = protect_technical_terms(protected)
|
||||
|
||||
try:
|
||||
translated = translate_one(
|
||||
protected,
|
||||
lang,
|
||||
args.provider,
|
||||
args.context,
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
translated = restore_technical_terms(translated, technical_terms)
|
||||
target_flat[key] = restore_placeholders(translated, placeholders)
|
||||
print(
|
||||
f" [{lang} {index}/{len(missing)}] {key}: "
|
||||
f"{en_value[:60]!r} → {target_flat[key][:60]!r}",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Fall back to whatever we already had (or EN) so the
|
||||
# runtime fallback still kicks in for this key.
|
||||
target_flat[key] = target_flat.get(key) or en_value
|
||||
failures_for_lang.append((lang, key, str(exc)))
|
||||
print(f" [{lang}] {key}: FAILED — {exc}", file=sys.stderr, flush=True)
|
||||
|
||||
if args.save_every > 0 and index % args.save_every == 0:
|
||||
# Preserve keys already in target_flat + write partial progress.
|
||||
write_json(locale_path, unflatten(target_flat))
|
||||
time.sleep(args.sleep)
|
||||
|
||||
write_json(locale_path, unflatten(target_flat))
|
||||
print(f" wrote {locale_path}", flush=True)
|
||||
total_failures.extend(failures_for_lang)
|
||||
|
||||
if total_failures:
|
||||
print(
|
||||
f"\nCompleted with {len(total_failures)} translation failures "
|
||||
f"(partial progress persisted, next run will retry).",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
for lang, key, error in total_failures[:20]:
|
||||
print(f" - {lang}: {key} → {error}", file=sys.stderr, flush=True)
|
||||
if len(total_failures) > 20:
|
||||
print(f" ... and {len(total_failures) - 20} more.", file=sys.stderr, flush=True)
|
||||
# Exit 0 on partial failure so the workflow's Commit + push
|
||||
# step still runs and the keys that DID translate reach
|
||||
# develop. The un-translated keys stay empty and the next
|
||||
# workflow tick (or a manual dispatch) retries them.
|
||||
# Previously we returned 2, which failed the whole run and
|
||||
# discarded 36 out of 38 successful translations because
|
||||
# 2 googletrans timeouts hit sv at the start of the burst.
|
||||
if not skipped:
|
||||
return 0
|
||||
|
||||
if skipped:
|
||||
names = ', '.join(dict.fromkeys(lang for lang in languages if lang in skipped))
|
||||
print(
|
||||
f"\nPartial failure: skipped {len(skipped)} locale(s): {names}. "
|
||||
"Valid locales processed; skipped catalogs unchanged. "
|
||||
"Manual reconciliation required; exiting 1.",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
return 1
|
||||
|
||||
print("\ni18n messages generated successfully.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -16,6 +16,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -28,12 +30,273 @@ from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt")
|
||||
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk", "sv")
|
||||
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
|
||||
# googletrans and the public Google endpoint used by this workflow do not
|
||||
# support Cloud Translation glossaries. Protect product names, package names
|
||||
# and command identifiers with opaque tokens before sending text to any
|
||||
# provider, then restore the exact source spelling afterwards. Keep longer
|
||||
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
|
||||
PROTECTED_TECHNICAL_TERMS = (
|
||||
# Product names, kept whole. Left to a translator, "Proxmox Backup Server"
|
||||
# became "Proxmox Sicherungsserver" and "Serveur de sauvegarde Proxmox",
|
||||
# and when a provider rightly handed it back unchanged the string was
|
||||
# retried and then dropped as untranslated.
|
||||
"Proxmox Backup Server",
|
||||
"Proxmox Datacenter Manager",
|
||||
"Proxmox Mail Gateway",
|
||||
"Proxmox VE",
|
||||
"ProxMenux Monitor",
|
||||
"ProxMenux Optimizations",
|
||||
"Secure Gateway",
|
||||
"Tailscale",
|
||||
"google/gasket-driver",
|
||||
"feranick/gasket-driver",
|
||||
"libedgetpu1-std",
|
||||
"Proxmox VE Helper-Scripts",
|
||||
"Docker Compose",
|
||||
"gasket-driver",
|
||||
"gasket-dkms",
|
||||
"libedgetpu1",
|
||||
"libedgetpu",
|
||||
"Google Coral",
|
||||
"Edge TPU",
|
||||
"ProxMenux",
|
||||
"Proxmox",
|
||||
"AppImage",
|
||||
"smartctl",
|
||||
"systemctl",
|
||||
"pveproxy",
|
||||
"apt-get",
|
||||
"Frigate",
|
||||
# Kernel driver and subsystem names. Left to a translator, `nouveau`
|
||||
# becomes the adjective it is borrowed from — "el nuevo controlador"
|
||||
# in Spanish, "Jugendstil" in German — and the reader is told about a
|
||||
# driver that does not exist instead of the one being blacklisted.
|
||||
"nvidia-container-toolkit",
|
||||
"nvidia-smi",
|
||||
"initramfs",
|
||||
"nouveau",
|
||||
"vfio-pci",
|
||||
"modprobe",
|
||||
"Docker",
|
||||
"Coral",
|
||||
"gasket",
|
||||
"apex",
|
||||
"lspci",
|
||||
"dpkg",
|
||||
"DKMS",
|
||||
"QEMU",
|
||||
"LXC",
|
||||
"ZFS",
|
||||
"SSH",
|
||||
"fork",
|
||||
# Vendor, API and acceleration names. A label like "NVIDIA (NVDEC/CUDA)"
|
||||
# is a product name end to end: every provider hands it back as it came,
|
||||
# and without these entries that correct answer is read as a failure and
|
||||
# the string is dropped from the catalogue.
|
||||
"VA-API",
|
||||
"NVIDIA",
|
||||
"NVDEC",
|
||||
"NVENC",
|
||||
"WebUI",
|
||||
"Intel",
|
||||
"CUDA",
|
||||
"ROCm",
|
||||
"Vulkan",
|
||||
"SYCL",
|
||||
"QSV",
|
||||
"OpenVINO",
|
||||
"OpenCL",
|
||||
"KFD",
|
||||
"GPU",
|
||||
"CPU",
|
||||
"AMD",
|
||||
# Proxmox's own words for a container and a virtual machine. Read as
|
||||
# initials they get reordered — "CT" comes back as "TC" in Spanish —
|
||||
# and the reader is told about something Proxmox does not call that.
|
||||
"CT",
|
||||
"VM",
|
||||
# Open Container Initiative. Read as a word it becomes "BEC" in French,
|
||||
# which named nothing, across thirty-one strings of the OCI section.
|
||||
"OCI manager Apps",
|
||||
# The file an application is installed from. Its name is the one of the
|
||||
# file on disk; translated, it names something the menu does not offer.
|
||||
"oci-compose",
|
||||
"OCI",
|
||||
"beta",
|
||||
# Debian release names. "Bookworm" is a bird and a reader of books, and
|
||||
# came back as "el libro de Debian"; the others are equally ordinary
|
||||
# words in the languages that borrowed them.
|
||||
"Bookworm",
|
||||
"Trixie",
|
||||
"Bullseye",
|
||||
"Buster",
|
||||
"Noble",
|
||||
"Jammy",
|
||||
)
|
||||
TECHNICAL_TERM_RE = re.compile(
|
||||
"|".join(
|
||||
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
|
||||
for term in sorted(PROTECTED_TECHNICAL_TERMS, key=len, reverse=True)
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TRANSLATE_CALL_RE = re.compile(
|
||||
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
|
||||
re.VERBOSE | re.DOTALL,
|
||||
)
|
||||
# Same shape the Monitor's generator uses, so a message reads identically
|
||||
# whichever of the two catalogues it comes from.
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
||||
# Literals a reader copies and runs. Translated as words they stop working:
|
||||
# /dev/apex_0 came back as "/dev/apex 0", --auto-uninstall as "desinstalación
|
||||
# automática", and "pct config 110" as "configuration PCT 110". A glossary can
|
||||
# only hold what someone listed; these are recognised by shape.
|
||||
STRUCTURAL_LITERAL_RE = re.compile(
|
||||
r"""
|
||||
/(?:dev|etc|usr|var|opt|srv|proc|sys|run|boot|tmp)/[\w./+-]*[\w/+-]
|
||||
| (?<![\w-])--[A-Za-z][\w-]*
|
||||
# Angle-bracket placeholders a reader substitutes: <CTID> came back as
|
||||
# "■CTID confiar", with the closing bracket translated as a verb.
|
||||
| <[A-Za-z][\w-]*>
|
||||
# Shell operators. "&&" became the HTML entity "'" in Spanish, and
|
||||
# the command it joined stopped being one command; a lone pipe came back
|
||||
# as the word "Silencio".
|
||||
| &&|\|\||(?<!\|)\|(?!\|)
|
||||
# Environment variable names. THELOUNGE_HOME lost its underscore and
|
||||
# became two words, so the command no longer set anything.
|
||||
| \b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b
|
||||
| \b[\w.-]+\.(?:sh|py|json|yml|yaml|conf|service|timer|func|cfg|list|log)\b
|
||||
# Reverse-DNS identifiers: org.opencontainers.image.version came back as
|
||||
# "org.open containers.image.version" — a label name with a space in it.
|
||||
| \b[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,}\b
|
||||
# A command plus a real subcommand. An allowlist, not "any word": with
|
||||
# a wildcard, "pvesm command not found" and "apt-get exited" were read as
|
||||
# invocations, and the strings a contributor had already translated by
|
||||
# hand were discarded as damaged.
|
||||
| \b(?:pct|qm)\s+(?:config|start|stop|shutdown|reboot|destroy|create|clone
|
||||
|enter|exec|push|pull|set|list|status|snapshot|rollback|resize|migrate
|
||||
|template|unlock|mount|unmount|df|fsck|restore|suspend|resume|monitor)\b
|
||||
| \bpvesm\s+(?:path|status|list|alloc|free|add|remove|set|scan|export|import)\b
|
||||
| \b(?:pveam|pveum|pvecm)\s+(?:available|download|list|update|remove|add|create
|
||||
|delete|status|nodes|keygen|expected|modify|set|useradd|userdel|passwd)\b
|
||||
| \bapt-get\s+(?:install|update|upgrade|remove|purge|autoremove|dist-upgrade|clean)\b
|
||||
| \bdpkg\s+(?:-l|-i|-r|-P|--configure|--get-selections)\b
|
||||
| \b(?:systemctl|journalctl)\s+(?:start|stop|restart|reload|enable|disable|status
|
||||
|daemon-reload|mask|unmask|is-active|is-enabled|list-units|-u|-f|-b)\b
|
||||
| \bzpool\s+(?:status|list|import|export|scrub|trim|get|set|history)\b
|
||||
| \bsmartctl\s+(?:-a|-A|-H|-i|-t|-l|-x|--all|--health|--info)\b
|
||||
| \b(?:modprobe|blkid|lsblk|mkfs)\s+(?:-a|-r|-n|-o|-f|-t|\.[a-z0-9]+)\b
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
# Providers that answer the same thing every time for the same input, so a
|
||||
# second attempt cannot produce a different result. `appimage` shells out to a
|
||||
# binary that may reach a network service, so it is not on the list.
|
||||
DETERMINISTIC_PROVIDERS = frozenset({"argos"})
|
||||
|
||||
|
||||
def protect_catalog_titles(directories) -> None:
|
||||
"""Add every application name in the catalog to the protected glossary.
|
||||
|
||||
They are product names, and a translator treats them as words: "HAOS One"
|
||||
comes back as "HAOS Man". Protecting them costs nothing and the failure it
|
||||
prevents reaches the reader as an application that does not exist.
|
||||
"""
|
||||
global TECHNICAL_TERM_RE
|
||||
titles: set[str] = set()
|
||||
for directory in directories:
|
||||
for path in sorted(Path(directory).rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
title = ((data.get("catalog_ui") or {}).get("title") or {}).get("en_US")
|
||||
if isinstance(title, str) and title.strip():
|
||||
titles.add(title.strip())
|
||||
if not titles:
|
||||
return
|
||||
terms = tuple(sorted(set(PROTECTED_TECHNICAL_TERMS) | titles, key=len, reverse=True))
|
||||
# Same word boundaries as the module-level pattern. Without them a short
|
||||
# term matches inside longer words, and every application title in the
|
||||
# catalogue joins the glossary here.
|
||||
TECHNICAL_TERM_RE = re.compile(
|
||||
"|".join(
|
||||
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
|
||||
for term in terms
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
print(f"Protected application names: {len(titles)}", flush=True)
|
||||
|
||||
|
||||
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
||||
"""Replace each ``{name}`` with a token before translation.
|
||||
|
||||
A provider reads the name inside the braces as a word and translates
|
||||
it: ``{count}`` comes back as ``{conta}`` in Portuguese and ``{počet}``
|
||||
in Slovak. The consumer then finds no placeholder to substitute and
|
||||
falls back to English, so the translation was paid for and never used.
|
||||
The token carries no underscores because argos splits on them.
|
||||
"""
|
||||
found: list[str] = []
|
||||
|
||||
def _swap(match: re.Match[str]) -> str:
|
||||
found.append(match.group(0))
|
||||
return f"PMXPH{len(found) - 1:03d}"
|
||||
|
||||
return PLACEHOLDER_RE.sub(_swap, text), found
|
||||
|
||||
|
||||
def restore_placeholders(text: str, found: list[str]) -> str:
|
||||
"""Put the original ``{name}`` back where each token landed."""
|
||||
for index, original in enumerate(found):
|
||||
text = text.replace(f"PMXPH{index:03d}", original)
|
||||
return text
|
||||
|
||||
|
||||
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
|
||||
"""Replace glossary terms with stable tokens before translation.
|
||||
|
||||
Structural literals go first. A glossary can only list what someone
|
||||
thought of, and what reaches the reader as a broken command is never on
|
||||
that list: an absolute path, a long option, a filename. They are
|
||||
recognised by shape instead, which also covers the next one added.
|
||||
"""
|
||||
protected: list[str] = []
|
||||
|
||||
def _swap(match: re.Match[str]) -> str:
|
||||
protected.append(match.group(0))
|
||||
return f"PMXTERM{len(protected) - 1:03d}"
|
||||
|
||||
text = STRUCTURAL_LITERAL_RE.sub(_swap, text)
|
||||
return TECHNICAL_TERM_RE.sub(_swap, text), protected
|
||||
|
||||
|
||||
def restore_technical_terms(text: str, protected: list[str]) -> str:
|
||||
"""Restore glossary terms exactly as they appeared in the source."""
|
||||
for index, original in enumerate(protected):
|
||||
text = text.replace(f"PMXTERM{index:03d}", original)
|
||||
return text
|
||||
|
||||
|
||||
# A provider can alter a token instead of carrying it through: argos returned
|
||||
# MPXTERM000 for PMXTERM000, the replace found nothing, and the token shipped
|
||||
# to the reader — "MPXTERM000 configuré" is in the French catalogue now.
|
||||
SENTINEL_RESIDUE_RE = re.compile(r"[MP][MPX]X?\s?(?:TERM|PH)\s?\d{2,4}", re.IGNORECASE)
|
||||
|
||||
|
||||
def assert_no_sentinel_residue(text: str) -> None:
|
||||
"""Fail the translation when a protection token did not survive intact.
|
||||
|
||||
Leaving the key out is recoverable: the next run tries again. Storing a
|
||||
string with a token in it is not, because nothing looks at a value that
|
||||
already exists.
|
||||
"""
|
||||
found = SENTINEL_RESIDUE_RE.search(text)
|
||||
if found:
|
||||
raise RuntimeError(f"the provider altered a protection token: {found.group(0)!r}")
|
||||
|
||||
|
||||
def iter_script_files(
|
||||
@@ -86,6 +349,93 @@ def extract_translate_texts(
|
||||
return sorted(found)
|
||||
|
||||
|
||||
PYTHON_TRANSLATE_CALLS = {"translate", "N_"}
|
||||
CATALOG_TEXT_KEYS = {"prompt", "enable_prompt", "path_prompt", "size_prompt", "label", "warning"}
|
||||
CATALOG_TEXT_LISTS = {"stack_completion_notes", "completion_notes"}
|
||||
|
||||
|
||||
def extract_python_texts(directories: Iterable[Path]) -> list[str]:
|
||||
"""translate("...") and N_("...") calls with a literal argument. The parser
|
||||
joins implicitly concatenated literals, so wrapped strings are found whole."""
|
||||
found: dict[str, None] = {}
|
||||
for directory in directories:
|
||||
for path in sorted(directory.rglob("*.py")):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (SyntaxError, UnicodeDecodeError):
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and getattr(node.func, "id", None) in PYTHON_TRANSLATE_CALLS
|
||||
and node.args and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)):
|
||||
text = node.args[0].value.strip()
|
||||
if text:
|
||||
found.setdefault(text, None)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def extract_catalog_texts(directories: Iterable[Path]) -> list[str]:
|
||||
"""User-visible text stored in the OCI catalog JSON: prompts, labels,
|
||||
warnings, completion notes, category labels and descriptive usernames."""
|
||||
found: dict[str, None] = {}
|
||||
|
||||
def add(value: object) -> None:
|
||||
if isinstance(value, str) and value.strip():
|
||||
found.setdefault(value.strip(), None)
|
||||
|
||||
def walk(value: object, key: str = "") -> None:
|
||||
if isinstance(value, dict):
|
||||
for child_key, child in value.items():
|
||||
if child_key in CATALOG_TEXT_KEYS:
|
||||
add(child)
|
||||
elif child_key in CATALOG_TEXT_LISTS and isinstance(child, list):
|
||||
for item in child:
|
||||
add(item)
|
||||
elif child_key == "username" and isinstance(child, str) and " " in child:
|
||||
add(child)
|
||||
elif child_key == "catalog_ui" and isinstance(child, dict):
|
||||
# What the application detail screen shows: the tagline,
|
||||
# and the description only where there is no tagline. The
|
||||
# catalog stores the source English; the translation lives
|
||||
# in the language cache with every other string.
|
||||
tagline = (child.get("tagline") or {}).get("en_US")
|
||||
add(tagline or (child.get("description") or {}).get("en_US"))
|
||||
elif child_key == "labels" and key == "" and isinstance(child, dict):
|
||||
for item in child.values():
|
||||
add(item)
|
||||
walk(child, child_key)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
walk(item, key)
|
||||
|
||||
for directory in directories:
|
||||
for path in sorted(directory.rglob("*.json")):
|
||||
try:
|
||||
walk(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def translate_argos(text: str, dest_lang: str) -> str:
|
||||
"""LibreTranslate's engine, running locally.
|
||||
|
||||
A public endpoint answers a few thousand strings and then starts
|
||||
refusing — and the library wrapper around it returns the English
|
||||
unchanged rather than raising, which writes the source text into the
|
||||
catalogue as if it were a translation. Local models have no quota and
|
||||
no silent failure mode.
|
||||
"""
|
||||
try:
|
||||
import argostranslate.translate as argos # type: ignore
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"argostranslate is not installed. Install argostranslate and the "
|
||||
"en->target packages, or run with another provider."
|
||||
) from exc
|
||||
return argos.translate(text, "en", dest_lang)
|
||||
|
||||
|
||||
def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
|
||||
try:
|
||||
from googletrans import Translator # type: ignore
|
||||
@@ -97,7 +447,10 @@ def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
|
||||
|
||||
translator = Translator()
|
||||
full_text = f"{context} {text}".strip()
|
||||
return translator.translate(full_text, dest=dest_lang).text
|
||||
result = translator.translate(full_text, dest=dest_lang)
|
||||
if inspect.isawaitable(result):
|
||||
result = asyncio.run(result)
|
||||
return result.text
|
||||
|
||||
|
||||
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
|
||||
@@ -163,8 +516,23 @@ def translate_appimage(
|
||||
|
||||
def clean_translation(value: str) -> str:
|
||||
separator = r"[\s\u00a0]*[::]"
|
||||
translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
|
||||
context_labels = "Context|Contexto|Contexte|Kontext|Contesto"
|
||||
# `Translate` pivot in every locale currently supported. Without
|
||||
# the target-language variant here, the cleaner can't locate the
|
||||
# boundary between the context prompt and the real translation,
|
||||
# and the whole payload leaks through as the translated context.
|
||||
# Caught on the 2026-08-14 workflow run — every sk / sv key ended
|
||||
# up as "Technický text používateľského rozhrania…" or
|
||||
# "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Övers"
|
||||
# because Preložiť / Översätt / Översätta were missing.
|
||||
translate_labels = (
|
||||
"Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
|
||||
"|Preložiť|Prelož|Preloz" # sk
|
||||
"|Översätta|Översätt" # sv
|
||||
)
|
||||
context_labels = (
|
||||
"Context|Contexto|Contexte|Kontext|Contesto"
|
||||
"|Sammanhang" # sv alternate
|
||||
)
|
||||
value = re.sub(
|
||||
rf"^.*?({translate_labels}){separator}",
|
||||
"",
|
||||
@@ -186,6 +554,20 @@ def clean_translation(value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
# Lowercase-or-digit, then the mark, then an uppercase letter: that is a
|
||||
# sentence boundary and not `8.0`, `storage.cfg` or `proxmox.com/docs`.
|
||||
_SENTENCE_RUN = re.compile(
|
||||
"(?<=[a-z0-9\u00e0-\u00ff\u0107\u010d\u011b\u013e\u0148\u0159\u0161\u0165\u016f\u017a\u017e])"
|
||||
"([.?!])"
|
||||
"(?=[A-Z\u00c0-\u00de\u0106\u010c\u011a\u013d\u0147\u0158\u0160\u0164\u016e\u0179\u017d])"
|
||||
)
|
||||
|
||||
|
||||
def restore_sentence_spacing(text: str) -> str:
|
||||
"""Every provider drops the blank between sentences it translated apart."""
|
||||
return _SENTENCE_RUN.sub(r"\1 ", text)
|
||||
|
||||
|
||||
def translate_text(
|
||||
text: str,
|
||||
dest_lang: str,
|
||||
@@ -194,15 +576,24 @@ def translate_text(
|
||||
timeout: int,
|
||||
appimage_path: Path,
|
||||
) -> str:
|
||||
if provider == "googletrans":
|
||||
translated = translate_googletrans(text, dest_lang, context)
|
||||
protected_text, placeholders = protect_placeholders(text)
|
||||
protected_text, protected_terms = protect_technical_terms(protected_text)
|
||||
if provider == "argos":
|
||||
translated = translate_argos(protected_text, dest_lang)
|
||||
elif provider == "googletrans":
|
||||
translated = translate_googletrans(protected_text, dest_lang, context)
|
||||
elif provider == "google-web":
|
||||
translated = translate_google_web(text, dest_lang, context, timeout)
|
||||
translated = translate_google_web(protected_text, dest_lang, context, timeout)
|
||||
elif provider == "appimage":
|
||||
translated = translate_appimage(text, dest_lang, context, timeout, appimage_path)
|
||||
translated = translate_appimage(
|
||||
protected_text, dest_lang, context, timeout, appimage_path
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
return clean_translation(translated) or text
|
||||
translated = restore_technical_terms(clean_translation(translated), protected_terms)
|
||||
translated = restore_placeholders(translated, placeholders)
|
||||
assert_no_sentinel_residue(translated)
|
||||
return restore_sentence_spacing(translated) if translated else text
|
||||
|
||||
|
||||
def load_language_cache(path: Path) -> dict[str, str]:
|
||||
@@ -217,6 +608,35 @@ def load_language_cache(path: Path) -> dict[str, str]:
|
||||
return {str(text): str(value) for text, value in data.items()}
|
||||
|
||||
|
||||
def is_fully_protected(source: str) -> bool:
|
||||
"""Whether the string is protected material and punctuation, nothing else.
|
||||
|
||||
"Docker Volume Backup" and "NVIDIA (NVDEC/CUDA)" are product and API
|
||||
names from end to end, and "apt-get exited" is a command with a word
|
||||
the command itself supplies. Coming back unchanged is the right answer
|
||||
for all of them, so the guard below must not read it as a silent
|
||||
failure and throw the result away. Both kinds of protection count:
|
||||
the glossary catches the names, the structural pattern the rest.
|
||||
"""
|
||||
remainder = STRUCTURAL_LITERAL_RE.sub(" ", source)
|
||||
return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", remainder))
|
||||
|
||||
|
||||
def looks_untranslated(source: str, result: str) -> bool:
|
||||
"""Whether a provider handed back the text it was given.
|
||||
|
||||
A single technical word legitimately survives translation — Docker, GPU,
|
||||
LXC — but a sentence coming back byte-identical means the provider failed
|
||||
without saying so. Accepting it writes English into the catalogue, where
|
||||
it counts as translated and is never looked at again.
|
||||
"""
|
||||
if source.strip() != result.strip():
|
||||
return False
|
||||
if is_fully_protected(source):
|
||||
return False
|
||||
return len([word for word in re.findall(r"[A-Za-z]{2,}", source)]) >= 3
|
||||
|
||||
|
||||
def write_language_cache(path: Path, cache: dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
@@ -232,6 +652,30 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
description="Extract translate calls from scripts/ and build json/cache.json."
|
||||
)
|
||||
parser.add_argument("--scripts-dir", default="scripts", type=Path)
|
||||
parser.add_argument(
|
||||
"--extra-dir",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help="Extra directory scanned for translate calls in .sh files. Repeatable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--python-dir",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help="Directory scanned for translate()/N_() calls in .py files. Repeatable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--catalog-dir",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help="Directory of OCI catalog JSON files with user-visible text. Repeatable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-file",
|
||||
action="append",
|
||||
@@ -260,11 +704,11 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--languages",
|
||||
default=",".join(DEFAULT_LANGUAGES),
|
||||
help="Comma-separated destination languages. Default: es,fr,de,it,pt",
|
||||
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("appimage", "googletrans", "google-web"),
|
||||
choices=("argos", "appimage", "googletrans", "google-web"),
|
||||
default="appimage",
|
||||
help="Translation provider to use. Default: appimage",
|
||||
)
|
||||
@@ -277,10 +721,21 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||
parser.add_argument("--timeout", default=30, type=int)
|
||||
parser.add_argument("--sleep", default=0.15, type=float)
|
||||
parser.add_argument("--retries", default=4, type=int,
|
||||
help="Attempts per string before giving up on it.")
|
||||
parser.add_argument("--retry-wait", default=15, type=float,
|
||||
help="Seconds before the first retry; it doubles each time.")
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="Translate all entries again instead of reusing existing cache values.",
|
||||
help="Retranslate the strings named by --refresh-keys. On its own it has "
|
||||
"no effect: an existing translation is never replaced.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refresh-keys",
|
||||
type=Path,
|
||||
help="File with one source string per line. Only these may be "
|
||||
"retranslated over an existing value, and only with --refresh.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extract-only",
|
||||
@@ -319,17 +774,45 @@ def main() -> int:
|
||||
return 1
|
||||
|
||||
texts = extract_translate_texts(scripts_dir, args.extra_file)
|
||||
for directory in args.extra_dir:
|
||||
if directory.is_dir():
|
||||
texts += extract_translate_texts(directory.resolve())
|
||||
texts += extract_python_texts(d.resolve() for d in args.python_dir if d.is_dir())
|
||||
catalog_dirs = [d.resolve() for d in args.catalog_dir if d.is_dir()]
|
||||
protect_catalog_titles(catalog_dirs)
|
||||
texts += extract_catalog_texts(catalog_dirs)
|
||||
texts = sorted(dict.fromkeys(text for text in texts if "$" not in text and "`" not in text))
|
||||
if args.limit > 0:
|
||||
texts = texts[: args.limit]
|
||||
existing_by_lang = {
|
||||
lang: load_language_cache(output_dir / f"{lang}.json")
|
||||
for lang in languages
|
||||
}
|
||||
next_by_lang: dict[str, dict[str, str]] = {lang: {} for lang in languages}
|
||||
# Seeded with what is already translated so a periodic save — or an
|
||||
# interrupted run — writes a superset of the file it replaces, never a
|
||||
# truncated one.
|
||||
next_by_lang: dict[str, dict[str, str]] = {
|
||||
lang: dict(existing_by_lang.get(lang, {})) for lang in languages
|
||||
}
|
||||
print(f"Found {len(texts)} unique translate strings.", flush=True)
|
||||
print(f"Output directory: {output_dir}", flush=True)
|
||||
print(f"Languages: {', '.join(languages)}", flush=True)
|
||||
|
||||
refresh_keys: set[str] = set()
|
||||
if args.refresh_keys is not None:
|
||||
if not args.refresh:
|
||||
print("--refresh-keys needs --refresh.", file=sys.stderr)
|
||||
return 1
|
||||
refresh_keys = {
|
||||
line.rstrip("\n")
|
||||
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
print(f"Refreshing {len(refresh_keys)} named strings.", flush=True)
|
||||
elif args.refresh:
|
||||
print("--refresh without --refresh-keys: existing translations are kept.",
|
||||
flush=True)
|
||||
|
||||
failures: list[tuple[str, str, str]] = []
|
||||
total = len(texts) * len(languages)
|
||||
done = 0
|
||||
@@ -340,7 +823,13 @@ def main() -> int:
|
||||
|
||||
for index, text in enumerate(texts, start=1):
|
||||
done += 1
|
||||
if not args.refresh and existing.get(text):
|
||||
# A translation that is already there is never replaced. Most of
|
||||
# this catalogue is hand-written by the people who speak the
|
||||
# language, and a machine pass over it trades their wording for
|
||||
# something worse without anyone seeing the swap. Only a string
|
||||
# named in --refresh-keys may be overwritten, so every overwrite
|
||||
# is one somebody asked for by name.
|
||||
if existing.get(text) and text not in refresh_keys:
|
||||
next_by_lang[lang][text] = existing[text]
|
||||
continue
|
||||
if args.extract_only:
|
||||
@@ -348,8 +837,14 @@ def main() -> int:
|
||||
continue
|
||||
|
||||
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
|
||||
# A rate limit is a "come back later", not an answer. Retrying with
|
||||
# a growing wait recovers it; giving up on the first one is what
|
||||
# left thousands of strings untranslated.
|
||||
value, last_error = None, None
|
||||
for attempt in range(1, args.retries + 1):
|
||||
unchanged = False
|
||||
try:
|
||||
next_by_lang[lang][text] = translate_text(
|
||||
value = translate_text(
|
||||
text,
|
||||
lang,
|
||||
args.provider,
|
||||
@@ -357,11 +852,36 @@ def main() -> int:
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
print(f" => {next_by_lang[lang][text][:100]}", flush=True)
|
||||
if looks_untranslated(text, value):
|
||||
value = None
|
||||
unchanged = True
|
||||
raise RuntimeError("the provider returned the source text unchanged")
|
||||
break
|
||||
except Exception as exc:
|
||||
next_by_lang[lang][text] = existing.get(text, text)
|
||||
failures.append((text, lang, str(exc)))
|
||||
print(f" failed: {exc}", file=sys.stderr, flush=True)
|
||||
last_error = exc
|
||||
# Unchanged text from a remote provider is how a rate limit
|
||||
# shows up, so it is worth waiting for. A local engine is
|
||||
# deterministic: asking again returns the same string, and
|
||||
# the backoff only buys minutes of sleeping per phrase.
|
||||
if unchanged and args.provider in DETERMINISTIC_PROVIDERS:
|
||||
break
|
||||
if attempt < args.retries:
|
||||
wait = args.retry_wait * (2 ** (attempt - 1))
|
||||
print(f" retry {attempt}/{args.retries - 1} in {wait}s: {exc}",
|
||||
file=sys.stderr, flush=True)
|
||||
time.sleep(wait)
|
||||
if value is not None:
|
||||
next_by_lang[lang][text] = value
|
||||
print(f" => {value[:100]}", flush=True)
|
||||
else:
|
||||
# The key is left out on purpose. Writing the English here
|
||||
# would count as a translation on the next run and the string
|
||||
# would never be translated again.
|
||||
previous = existing.get(text)
|
||||
if previous:
|
||||
next_by_lang[lang][text] = previous
|
||||
failures.append((text, lang, str(last_error)))
|
||||
print(f" failed: {last_error}", file=sys.stderr, flush=True)
|
||||
if args.save_every > 0 and index % args.save_every == 0:
|
||||
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
|
||||
time.sleep(args.sleep)
|
||||
@@ -373,12 +893,20 @@ def main() -> int:
|
||||
write_language_cache(output_dir / f"{lang}.json", cache)
|
||||
|
||||
if failures:
|
||||
print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True)
|
||||
print(
|
||||
f"Completed with {len(failures)} translation failures "
|
||||
f"(partial progress persisted, next run will retry).",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
for text, lang, error in failures[:20]:
|
||||
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
|
||||
if len(failures) > 20:
|
||||
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
|
||||
return 2
|
||||
# Exit 0 on partial failure so the workflow's Commit + push
|
||||
# step still runs. Otherwise a couple of transient googletrans
|
||||
# timeouts would fail the whole workflow and discard every
|
||||
# successful translation from the same batch.
|
||||
return 0
|
||||
|
||||
print("Translation cache generated successfully.", flush=True)
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,970 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build missing translations for the ProxMenux documentation catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from build_translation_cache import ( # noqa: E402
|
||||
clean_translation,
|
||||
translate_appimage,
|
||||
translate_argos,
|
||||
translate_google_web,
|
||||
translate_googletrans,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
|
||||
DEFAULT_CONTEXT = (
|
||||
"Context: ProxMenux technical documentation for Proxmox VE users. "
|
||||
"Preserve product names, commands, paths, variables and placeholders. Translate:"
|
||||
)
|
||||
|
||||
TECHNICAL_TERMS = (
|
||||
"Proxmox VE Helper-Scripts",
|
||||
"Proxmox Backup Server",
|
||||
"Proxmox Mail Gateway",
|
||||
"Proxmox VE",
|
||||
"ProxMenux Monitor",
|
||||
"ProxMenux Scripts",
|
||||
"Docker Compose",
|
||||
"Docker Engine",
|
||||
"Google Coral",
|
||||
"Edge TPU",
|
||||
"Let's Encrypt",
|
||||
"Cloudflare",
|
||||
"Pushover",
|
||||
"Telegram",
|
||||
"Discord",
|
||||
"Microsoft Teams",
|
||||
"GitHub",
|
||||
"Gotify",
|
||||
"Apprise",
|
||||
"Frigate",
|
||||
"Vaultwarden",
|
||||
"Portainer",
|
||||
"ProxMenux",
|
||||
"AppImage",
|
||||
"systemctl",
|
||||
"journalctl",
|
||||
"smartctl",
|
||||
"pveproxy",
|
||||
"apt-get",
|
||||
"gasket-dkms",
|
||||
"libedgetpu",
|
||||
"QEMU",
|
||||
"LXC",
|
||||
"ZFS",
|
||||
"Ceph",
|
||||
"Docker",
|
||||
"OpenAI",
|
||||
"WebSocket",
|
||||
"OAuth",
|
||||
"DKMS",
|
||||
"SSH",
|
||||
"API",
|
||||
"Flask",
|
||||
"SQLite",
|
||||
"Prometheus",
|
||||
"Grafana",
|
||||
"Uptime Kuma",
|
||||
"PWA",
|
||||
"Fail2Ban",
|
||||
"Tailscale",
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"Next.js",
|
||||
"Coral",
|
||||
"rclone",
|
||||
"journald",
|
||||
"gasket",
|
||||
"apex",
|
||||
"Alpine",
|
||||
"OpenVINO",
|
||||
)
|
||||
|
||||
PROTECTED_PATTERNS = (
|
||||
# Keep rich-text tags visible to Google Translate. It preserves their
|
||||
# structure while translating the enclosed prose, whereas replacing
|
||||
# opening/closing tags with adjacent sentinels can make the provider drop
|
||||
# one side of the pair. The contract is validated after translation.
|
||||
re.compile(r"`[^`]+`"),
|
||||
re.compile(r"https?://[^\s<>]+"),
|
||||
re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}"),
|
||||
re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"),
|
||||
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
|
||||
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
|
||||
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
|
||||
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
|
||||
# Identifiers and encoded delimiters remain literal even outside <code>.
|
||||
re.compile(r"(?<![A-Za-z0-9_])_?[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+(?![A-Za-z0-9_])"),
|
||||
re.compile(r"\b[A-Za-z0-9]*(?:[a-z][A-Z])[A-Za-z0-9]*\b"),
|
||||
re.compile(r"&(?:[A-Za-z]+|#[0-9]+|#x[0-9A-Fa-f]+);"),
|
||||
)
|
||||
LITERAL_TAG_RE = re.compile(
|
||||
r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
TERM_RE = re.compile(
|
||||
"|".join(
|
||||
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
|
||||
for term in sorted(TECHNICAL_TERMS, key=len, reverse=True)
|
||||
),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
|
||||
# Every tag as written, attributes and self-closing slash included.
|
||||
ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
||||
# Set from --provider: a local model rewrites bracketed sentinels, a remote
|
||||
# one drops bare ones beside inline markup. See protect_text.
|
||||
BARE_SENTINELS = False
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||
NON_TRANSLATABLE_KEYS = {
|
||||
"accent",
|
||||
"categoryslug",
|
||||
"command",
|
||||
"code",
|
||||
"href",
|
||||
"icon",
|
||||
"iconbg",
|
||||
"id",
|
||||
"menucmd",
|
||||
"path",
|
||||
"route",
|
||||
"slug",
|
||||
"src",
|
||||
"url",
|
||||
"color",
|
||||
"tone",
|
||||
"variant",
|
||||
}
|
||||
SOURCE_STATE_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Leaf:
|
||||
path: tuple[str | int, ...]
|
||||
source: str
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def iter_leaves(node: Any, path: tuple[str | int, ...] = ()) -> list[Leaf]:
|
||||
leaves: list[Leaf] = []
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
leaves.extend(iter_leaves(value, path + (key,)))
|
||||
elif isinstance(node, list):
|
||||
for index, value in enumerate(node):
|
||||
leaves.extend(iter_leaves(value, path + (index,)))
|
||||
elif isinstance(node, str):
|
||||
leaves.append(Leaf(path, node))
|
||||
return leaves
|
||||
|
||||
|
||||
def get_at_path(node: Any, path: tuple[str | int, ...]) -> Any:
|
||||
current = node
|
||||
try:
|
||||
for part in path:
|
||||
if isinstance(part, int):
|
||||
if not isinstance(current, list):
|
||||
return None
|
||||
current = current[part]
|
||||
else:
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
current = current[part]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def path_key(path: tuple[str | int, ...]) -> str:
|
||||
return ".".join(str(part) for part in path)
|
||||
|
||||
|
||||
def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
|
||||
if not source.strip() or not re.search(r"[A-Za-z]", source):
|
||||
return True
|
||||
last = str(path[-1]).lower() if path else ""
|
||||
# These fields describe the surrounding snippet; they are prose, even
|
||||
# though their names end in "Code".
|
||||
if last in {"aftercode", "beforecode"}:
|
||||
return False
|
||||
# This UI breadcrumb is prose, rather than a filesystem path.
|
||||
if last == "uipath" and "<strong>" in source:
|
||||
return False
|
||||
if last == "flow" and source in {"loader", "dedicated"}:
|
||||
return True
|
||||
# These exact values drive rendering/navigation; similarly named visible
|
||||
# labels remain translatable.
|
||||
if last == "branch" and source in {"main", "develop", "feature/*"}:
|
||||
return True
|
||||
if last == "kind" and source in {"internal", "external"}:
|
||||
return True
|
||||
if last == "key" and source in {"linux", "nas", "windows"}:
|
||||
return True
|
||||
if last in NON_TRANSLATABLE_KEYS or any(
|
||||
last.endswith(suffix)
|
||||
for suffix in ("url", "href", "path", "command", "code", "icon")
|
||||
):
|
||||
return True
|
||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/\S+", source):
|
||||
return True
|
||||
# Standalone literal blocks contain examples, not prose. Separators between
|
||||
# several blocks do not make their command/configuration contents translatable.
|
||||
without_literals = LITERAL_TAG_RE.sub("", source)
|
||||
if without_literals != source and not re.search(r"[A-Za-z]", ANY_TAG_RE.sub("", without_literals)):
|
||||
return True
|
||||
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def leaf_token(path: tuple[str | int, ...]) -> str:
|
||||
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def literal_values_match(source: Any, target: Any) -> bool:
|
||||
"""Literal commands and rendering metadata must follow the source too."""
|
||||
return all(
|
||||
not should_copy(leaf.source, leaf.path)
|
||||
or get_at_path(target, leaf.path) == leaf.source
|
||||
for leaf in iter_leaves(source)
|
||||
)
|
||||
|
||||
|
||||
def source_fingerprints(source: Any) -> dict[str, str]:
|
||||
return {
|
||||
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
|
||||
for leaf in iter_leaves(source)
|
||||
}
|
||||
|
||||
|
||||
def needs_translation(
|
||||
source: str,
|
||||
target: Any,
|
||||
path: tuple[str | int, ...],
|
||||
forced_tokens: set[str] | None = None,
|
||||
) -> bool:
|
||||
if should_copy(source, path):
|
||||
return False
|
||||
# An existing translation is never replaced. Only a leaf whose token was
|
||||
# named on the command line may be retranslated over a real value, so an
|
||||
# overwrite is always one somebody asked for rather than a side effect.
|
||||
if forced_tokens and leaf_token(path) in forced_tokens:
|
||||
return True
|
||||
return not isinstance(target, str) or not target.strip() or target == source
|
||||
|
||||
|
||||
def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
|
||||
"""Give translatable rich-text tags opaque names during translation.
|
||||
|
||||
Google can remove semantic tags such as ``strong`` or ``em`` after
|
||||
translating their contents. Unknown tag names are retained, so rename
|
||||
non-literal tags temporarily and restore them before contract validation.
|
||||
Literal ``code``, ``kbd`` and ``pre`` elements stay untouched because the
|
||||
provider preserves both their markup and their contents.
|
||||
"""
|
||||
|
||||
names: dict[str, str] = {}
|
||||
reverse: dict[str, str] = {}
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
original = match.group(1)
|
||||
if original.lower() in {"code", "codenw", "codexs", "kbd", "pre"}:
|
||||
return match.group(0)
|
||||
key = original.lower()
|
||||
internal = names.get(key)
|
||||
if internal is None:
|
||||
internal = f"pmxrich{len(names):04d}"
|
||||
names[key] = internal
|
||||
reverse[internal] = original
|
||||
slash = "/" if match.group(0).startswith("</") else ""
|
||||
return f"<{slash}{internal}>"
|
||||
|
||||
return TAG_RE.sub(replace, text), reverse
|
||||
|
||||
|
||||
def restore_rich_tags(text: str, mapping: dict[str, str]) -> str:
|
||||
for internal, original in mapping.items():
|
||||
text = re.sub(
|
||||
rf"<(/?){re.escape(internal)}>",
|
||||
lambda match: f"<{match.group(1)}{original}>",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
||||
# Google already keeps both the markup and the contents of literal rich-
|
||||
# text blocks. Replacing a filename or path inside one of these blocks can
|
||||
# leave the sentinel as its only child, which the provider may discard.
|
||||
literal_ranges = [(match.start(), match.end()) for match in LITERAL_TAG_RE.finditer(text)]
|
||||
|
||||
def overlaps_literal(start: int, end: int) -> bool:
|
||||
return any(start < literal_end and end > literal_start for literal_start, literal_end in literal_ranges)
|
||||
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for pattern in PROTECTED_PATTERNS:
|
||||
candidates.extend(
|
||||
(match.start(), match.end())
|
||||
for match in pattern.finditer(text)
|
||||
if not overlaps_literal(match.start(), match.end())
|
||||
)
|
||||
candidates.extend(
|
||||
(match.start(), match.end())
|
||||
for match in TERM_RE.finditer(text)
|
||||
if not overlaps_literal(match.start(), match.end())
|
||||
)
|
||||
if BARE_SENTINELS:
|
||||
# A remote provider carries inline tags through untouched, so they are
|
||||
# only validated afterwards. A local model rewrites them — dropping a
|
||||
# </code>, merging two <em> — and the whole file is then discarded for
|
||||
# a broken contract. Protecting them costs the translator the tag as
|
||||
# context and saves the paragraph.
|
||||
candidates.extend((match.start(), match.end()) for match in ANY_TAG_RE.finditer(text))
|
||||
# A local model also rewrites what a literal block holds: it drops the
|
||||
# underscores of an identifier, translates a subcommand and turns a
|
||||
# pipe into punctuation. The whole block is one opaque token instead.
|
||||
candidates.extend(literal_ranges)
|
||||
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
|
||||
|
||||
selected: list[tuple[int, int]] = []
|
||||
cursor = -1
|
||||
for start, end in candidates:
|
||||
if start >= cursor:
|
||||
selected.append((start, end))
|
||||
cursor = end
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
chunks: list[str] = []
|
||||
cursor = 0
|
||||
for index, (start, end) in enumerate(selected):
|
||||
# Google Translate can drop underscore-delimited sentinels when they
|
||||
# sit directly beside inline markup (for example an opening <em>
|
||||
# token followed by translated prose). Triple brackets remain opaque
|
||||
# in that position and preserve the complete rich-text contract.
|
||||
#
|
||||
# A local model treats the brackets as punctuation instead, and
|
||||
# rewrites them: argos returns [PMXDOC0000] in German, [[PMXDOC0000]]
|
||||
# in Slovak and [[[[PMXDOC0000]]]]] in Italian. The bare identifier
|
||||
# survives every provider tried, so it is what a local run uses.
|
||||
token = f"PMXDOC{index:04d}" if BARE_SENTINELS else f"[[[PMXDOC{index:04d}]]]"
|
||||
chunks.append(text[cursor:start])
|
||||
chunks.append(token)
|
||||
mapping[token] = text[start:end]
|
||||
cursor = end
|
||||
chunks.append(text[cursor:])
|
||||
return "".join(chunks), mapping
|
||||
|
||||
|
||||
def restore_text(text: str, mapping: dict[str, str]) -> str:
|
||||
# A local model may append a digit to a bare sentinel. A substring match
|
||||
# would silently restore the prefix and leak the extra digit into prose.
|
||||
patterns = {token: re.compile(re.escape(token) + r"(?![0-9])") for token in mapping}
|
||||
missing = [token for token, pattern in patterns.items() if not pattern.search(text)]
|
||||
if missing:
|
||||
raise ValueError(f"translation provider changed protected token {missing[0]}")
|
||||
for token, original in mapping.items():
|
||||
text = patterns[token].sub(lambda match: original, text)
|
||||
return text
|
||||
|
||||
|
||||
def validate_contract(source: str, target: str) -> None:
|
||||
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
|
||||
raise ValueError("rich-text tag contract changed")
|
||||
if sorted(ANY_TAG_RE.findall(source)) != sorted(ANY_TAG_RE.findall(target)):
|
||||
raise ValueError("markup changed")
|
||||
literal = lambda text: sorted(match.group(0) for match in LITERAL_TAG_RE.finditer(text))
|
||||
if literal(source) != literal(target):
|
||||
raise ValueError("literal code changed")
|
||||
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||
raise ValueError("placeholder contract changed")
|
||||
# Bare numeric protection markers once leaked a trailing zero just
|
||||
# outside an otherwise intact code span. Literal/ICU checks cannot see it.
|
||||
residue = re.compile(r'(?:</(?:code|codeNw|codeXs|pre|kbd)>|<strong>)(0+)(?=\s|[;:/<().,-]|$)')
|
||||
if len(residue.findall(target)) > len(residue.findall(source)):
|
||||
raise ValueError("unexpected numeric residue next to literal markup")
|
||||
|
||||
|
||||
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||
def translate(text: str, language: str) -> str:
|
||||
if args.provider == "argos":
|
||||
raw = translate_argos(text, language)
|
||||
elif args.provider == "googletrans":
|
||||
raw = translate_googletrans(text, language, args.context)
|
||||
elif args.provider == "google-web":
|
||||
raw = translate_google_web(text, language, args.context, args.timeout)
|
||||
else:
|
||||
raw = translate_appimage(
|
||||
text,
|
||||
language,
|
||||
args.context,
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
return clean_translation(raw).strip()
|
||||
|
||||
return translate
|
||||
|
||||
|
||||
def translate_with_retry(
|
||||
source: str,
|
||||
language: str,
|
||||
translate: Callable[[str, str], str],
|
||||
retries: int,
|
||||
delay: float,
|
||||
) -> str:
|
||||
rich_text, rich_mapping = protect_rich_tags(source)
|
||||
protected, mapping = protect_text(rich_text)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
translated = translate(protected, language)
|
||||
if not translated:
|
||||
raise ValueError("translation provider returned an empty value")
|
||||
translated = restore_text(translated, mapping)
|
||||
translated = restore_rich_tags(translated, rich_mapping)
|
||||
validate_contract(source, translated)
|
||||
return translated
|
||||
except Exception as exc: # network/provider errors are retried together
|
||||
last_error = exc
|
||||
if attempt < retries:
|
||||
time.sleep(delay * (attempt + 1))
|
||||
raise RuntimeError(str(last_error)) from last_error
|
||||
|
||||
|
||||
def merge_tree(
|
||||
source: Any,
|
||||
target: Any,
|
||||
translations: dict[tuple[str | int, ...], str],
|
||||
path: tuple[str | int, ...] = (),
|
||||
) -> Any:
|
||||
if isinstance(source, dict):
|
||||
target_dict = target if isinstance(target, dict) else {}
|
||||
return {
|
||||
key: merge_tree(value, target_dict.get(key), translations, path + (key,))
|
||||
for key, value in source.items()
|
||||
}
|
||||
if isinstance(source, list):
|
||||
target_list = target if isinstance(target, list) else []
|
||||
return [
|
||||
merge_tree(
|
||||
value,
|
||||
target_list[index] if index < len(target_list) else None,
|
||||
translations,
|
||||
path + (index,),
|
||||
)
|
||||
for index, value in enumerate(source)
|
||||
]
|
||||
if isinstance(source, str):
|
||||
if path in translations:
|
||||
return translations[path]
|
||||
if should_copy(source, path):
|
||||
return source
|
||||
if isinstance(target, str) and target.strip():
|
||||
return target
|
||||
return source
|
||||
return source
|
||||
|
||||
|
||||
def source_files(source_root: Path, section: str) -> list[Path]:
|
||||
scope = (source_root / section).resolve()
|
||||
root = source_root.resolve()
|
||||
if scope != root and root not in scope.parents:
|
||||
raise ValueError("section must stay inside the English messages directory")
|
||||
if scope.is_file():
|
||||
if scope.suffix != ".json":
|
||||
raise ValueError("section file must be JSON")
|
||||
return [scope]
|
||||
if not scope.is_dir():
|
||||
raise ValueError(f"section does not exist: {scope}")
|
||||
return sorted(
|
||||
path
|
||||
for path in scope.rglob("*.json")
|
||||
if not any(part.startswith(".") for part in path.relative_to(root).parts)
|
||||
)
|
||||
|
||||
|
||||
def collect_memory(source_root: Path, messages_root: Path, language: str) -> dict[str, str]:
|
||||
memory: dict[str, str] = {}
|
||||
conflicts: set[str] = set()
|
||||
for source_path in sorted(source_root.rglob("*.json")):
|
||||
if any(part.startswith(".") for part in source_path.relative_to(source_root).parts):
|
||||
continue
|
||||
target_path = messages_root / language / source_path.relative_to(source_root)
|
||||
source = read_json(source_path)
|
||||
target = read_json(target_path)
|
||||
if target is None:
|
||||
continue
|
||||
for leaf in iter_leaves(source):
|
||||
translated = get_at_path(target, leaf.path)
|
||||
if not isinstance(translated, str) or not translated.strip() or translated == leaf.source:
|
||||
continue
|
||||
previous = memory.get(leaf.source)
|
||||
if previous is not None and previous != translated:
|
||||
conflicts.add(leaf.source)
|
||||
else:
|
||||
memory[leaf.source] = translated
|
||||
for source in conflicts:
|
||||
memory.pop(source, None)
|
||||
return memory
|
||||
|
||||
|
||||
def pending_leaves(
|
||||
source: Any,
|
||||
target: Any,
|
||||
forced_tokens: set[str] | None = None,
|
||||
) -> list[Leaf]:
|
||||
return [
|
||||
leaf
|
||||
for leaf in iter_leaves(source)
|
||||
if needs_translation(
|
||||
leaf.source,
|
||||
get_at_path(target, leaf.path),
|
||||
leaf.path,
|
||||
forced_tokens,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def schema_matches(source: Any, target: Any) -> bool:
|
||||
if type(source) is not type(target):
|
||||
return False
|
||||
if isinstance(source, dict):
|
||||
return list(source) == list(target) and all(
|
||||
schema_matches(source[key], target[key]) for key in source
|
||||
)
|
||||
if isinstance(source, list):
|
||||
return len(source) == len(target) and all(
|
||||
schema_matches(left, right) for left, right in zip(source, target)
|
||||
)
|
||||
if isinstance(source, str):
|
||||
# Localized strings intentionally differ from the English source.
|
||||
return True
|
||||
# Booleans, numbers and null values are structural data and must stay in
|
||||
# sync with the English catalog instead of retaining an obsolete value.
|
||||
return source == target
|
||||
|
||||
|
||||
def empty_source_state() -> dict[str, Any]:
|
||||
return {
|
||||
"version": SOURCE_STATE_VERSION,
|
||||
"initialized": False,
|
||||
"source": {},
|
||||
"pending": {},
|
||||
}
|
||||
|
||||
|
||||
def load_source_state(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return empty_source_state()
|
||||
try:
|
||||
value = read_json(path)
|
||||
except ValueError:
|
||||
return empty_source_state()
|
||||
if not isinstance(value, dict) or value.get("version") != SOURCE_STATE_VERSION:
|
||||
return empty_source_state()
|
||||
if not isinstance(value.get("source"), dict) or not isinstance(value.get("pending"), dict):
|
||||
return empty_source_state()
|
||||
value.setdefault("initialized", True)
|
||||
return value
|
||||
|
||||
|
||||
def state_pending_tokens(state: dict[str, Any], language: str, relative: str) -> set[str]:
|
||||
language_state = state.setdefault("pending", {}).setdefault(language, {})
|
||||
values = language_state.get(relative, [])
|
||||
if not isinstance(values, list):
|
||||
return set()
|
||||
return {str(value) for value in values}
|
||||
|
||||
|
||||
def set_state_pending_tokens(
|
||||
state: dict[str, Any], language: str, relative: str, tokens: set[str]
|
||||
) -> None:
|
||||
language_state = state.setdefault("pending", {}).setdefault(language, {})
|
||||
if tokens:
|
||||
language_state[relative] = sorted(tokens)
|
||||
else:
|
||||
language_state.pop(relative, None)
|
||||
if not language_state:
|
||||
state["pending"].pop(language, None)
|
||||
|
||||
|
||||
def translate_file(
|
||||
source: Any,
|
||||
target: Any,
|
||||
leaves: list[Leaf],
|
||||
language: str,
|
||||
memory: dict[str, str],
|
||||
translate: Callable[[str, str], str],
|
||||
args: argparse.Namespace,
|
||||
) -> tuple[Any | None, list[str], int, set[str]]:
|
||||
resolved: dict[tuple[str | int, ...], str] = {}
|
||||
failures: list[str] = []
|
||||
failed_tokens: set[str] = set()
|
||||
jobs: dict[str, list[tuple[str | int, ...]]] = {}
|
||||
|
||||
for leaf in leaves:
|
||||
if leaf.source in memory:
|
||||
resolved[leaf.path] = memory[leaf.source]
|
||||
continue
|
||||
jobs.setdefault(leaf.source, []).append(leaf.path)
|
||||
|
||||
if jobs:
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
translate_with_retry,
|
||||
text,
|
||||
language,
|
||||
translate,
|
||||
args.retries,
|
||||
args.retry_delay,
|
||||
): text
|
||||
for text in jobs
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
text = futures[future]
|
||||
try:
|
||||
translated = future.result()
|
||||
memory[text] = translated
|
||||
for path in jobs[text]:
|
||||
resolved[path] = translated
|
||||
except Exception as exc:
|
||||
failures.append(f"{text[:90]}: {exc}")
|
||||
failed_tokens.update(leaf_token(path) for path in jobs[text])
|
||||
if args.sleep:
|
||||
time.sleep(args.sleep)
|
||||
|
||||
# What did translate is kept. merge_tree leaves a string it has no
|
||||
# translation for as whatever the locale already held, or as the English
|
||||
# source, so a file is written with the strings that worked rather than
|
||||
# discarded whole: a dense paragraph that a provider cannot carry its
|
||||
# protected tokens through used to take the other hundred and seventy
|
||||
# strings of its file with it.
|
||||
return merge_tree(source, target, resolved), failures, len(jobs), failed_tokens
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-dir", type=Path, default=Path("web/messages/en"))
|
||||
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
|
||||
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument(
|
||||
"--section",
|
||||
default=".",
|
||||
help="Relative file or directory below the English messages directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("argos", "google-web", "googletrans", "appimage"),
|
||||
default="googletrans",
|
||||
help=(
|
||||
"Translation provider. The default is what CI uses; `argos` runs "
|
||||
"locally with no quota, which is what a corpus this size needs."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
|
||||
parser.add_argument(
|
||||
"--source-state",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"Source fingerprint state used to detect changed English strings. "
|
||||
"Defaults to <messages-dir>/.docs-i18n-source-state.json."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||
parser.add_argument("--timeout", type=int, default=30)
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--retries", type=int, default=3)
|
||||
parser.add_argument("--retry-delay", type=float, default=2.0)
|
||||
parser.add_argument("--sleep", type=float, default=0.0)
|
||||
parser.add_argument(
|
||||
"--max-files",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Maximum pending files per locale; zero processes all files.",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--check", action="store_true")
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="Overwrite existing translations in the selected scope.",
|
||||
)
|
||||
parser.add_argument("--strict", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global BARE_SENTINELS
|
||||
args = build_parser().parse_args()
|
||||
BARE_SENTINELS = args.provider == "argos"
|
||||
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
|
||||
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
source_root = args.source_dir.resolve()
|
||||
messages_root = args.messages_dir.resolve()
|
||||
source_state_path = (
|
||||
args.source_state.resolve()
|
||||
if args.source_state is not None
|
||||
else messages_root / ".docs-i18n-source-state.json"
|
||||
)
|
||||
languages = [item.strip() for item in args.languages.split(",") if item.strip()]
|
||||
try:
|
||||
files = source_files(source_root, args.section)
|
||||
except ValueError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not languages:
|
||||
print("No target languages selected.", file=sys.stderr)
|
||||
return 2
|
||||
if args.refresh:
|
||||
print("--refresh no longer overwrites existing translations; "
|
||||
"name the leaves with --tokens instead.")
|
||||
|
||||
translate = provider_function(args)
|
||||
state = load_source_state(source_state_path)
|
||||
state_was_initialized = bool(state.get("initialized"))
|
||||
previous_source_state = state.setdefault("source", {})
|
||||
|
||||
file_info: dict[
|
||||
str, tuple[Path, Any, dict[str, str], set[str], set[str]]
|
||||
] = {}
|
||||
selected_relatives: set[str] = set()
|
||||
for source_path in files:
|
||||
relative = source_path.relative_to(source_root).as_posix()
|
||||
selected_relatives.add(relative)
|
||||
source = read_json(source_path)
|
||||
current_fingerprints = source_fingerprints(source)
|
||||
translatable_tokens = {
|
||||
leaf_token(leaf.path)
|
||||
for leaf in iter_leaves(source)
|
||||
if not should_copy(leaf.source, leaf.path)
|
||||
}
|
||||
previous_fingerprints = previous_source_state.get(relative, {})
|
||||
if not isinstance(previous_fingerprints, dict):
|
||||
previous_fingerprints = {}
|
||||
if state_was_initialized:
|
||||
changed_tokens = {
|
||||
token
|
||||
for token, fingerprint in current_fingerprints.items()
|
||||
if token in translatable_tokens
|
||||
# Existing English leaves whose text changed must be sent to
|
||||
# the provider even when the target still contains the old,
|
||||
# non-empty translation. New leaves are handled by the usual
|
||||
# missing-target detection, preserving a translation supplied
|
||||
# manually in the same commit.
|
||||
and token in previous_fingerprints
|
||||
and previous_fingerprints[token] != fingerprint
|
||||
}
|
||||
else:
|
||||
# The first run establishes the baseline without replacing
|
||||
# existing human/Codex translations. Missing target values are
|
||||
# still discovered separately for every locale below.
|
||||
changed_tokens = set()
|
||||
file_info[relative] = (
|
||||
source_path,
|
||||
source,
|
||||
current_fingerprints,
|
||||
changed_tokens,
|
||||
translatable_tokens,
|
||||
)
|
||||
|
||||
# Materialize every locale's pending queue before advancing the shared
|
||||
# English baseline. If the runner stops halfway through, unprocessed
|
||||
# locales retain the exact changed leaf tokens for the next run.
|
||||
for language in languages:
|
||||
for relative, (
|
||||
_,
|
||||
source,
|
||||
_,
|
||||
changed_tokens,
|
||||
translatable_tokens,
|
||||
) in file_info.items():
|
||||
target_path = messages_root / language / relative
|
||||
target = read_json(target_path)
|
||||
pending_tokens = (
|
||||
state_pending_tokens(state, language, relative)
|
||||
& translatable_tokens
|
||||
)
|
||||
pending_tokens.update(changed_tokens)
|
||||
pending_tokens.update(
|
||||
leaf_token(leaf.path)
|
||||
for leaf in pending_leaves(source, target)
|
||||
)
|
||||
set_state_pending_tokens(state, language, relative, pending_tokens)
|
||||
|
||||
for relative, (_, _, current_fingerprints, _, _) in file_info.items():
|
||||
previous_source_state[relative] = current_fingerprints
|
||||
state["initialized"] = True
|
||||
|
||||
# A complete run also mirrors deletion of an English catalog. Partial
|
||||
# --section runs deliberately leave unrelated paths untouched.
|
||||
if args.section in (".", ""):
|
||||
removed_files = set(previous_source_state) - selected_relatives
|
||||
for relative in sorted(removed_files):
|
||||
for language in languages:
|
||||
target_path = messages_root / language / relative
|
||||
if target_path.exists() and not (args.check or args.dry_run):
|
||||
target_path.unlink()
|
||||
print(f"[{language}] removed obsolete catalog {target_path}")
|
||||
set_state_pending_tokens(state, language, relative, set())
|
||||
previous_source_state.pop(relative, None)
|
||||
|
||||
if not (args.check or args.dry_run):
|
||||
write_json(source_state_path, state)
|
||||
|
||||
total_failures = 0
|
||||
total_written = 0
|
||||
# The provider decides the sentinel format, so a run that cannot be
|
||||
# attributed to one cannot be diagnosed afterwards: a night of failures
|
||||
# reading "translation provider changed protected token" left no way to
|
||||
# tell whether the provider was the one the tokens were shaped for.
|
||||
print(f"Provider: {args.provider} | bare sentinels: {BARE_SENTINELS}")
|
||||
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
|
||||
|
||||
for language in languages:
|
||||
memory = collect_memory(source_root, messages_root, language)
|
||||
# A target value paired with a newly changed English source is the old
|
||||
# translation, not valid translation memory for the new sentence.
|
||||
# Remove every queued source text before provider reuse; successful
|
||||
# translations repopulate memory normally for later files.
|
||||
queued_source_texts: set[str] = set()
|
||||
for relative_key, (_, source, _, _, _) in file_info.items():
|
||||
queued_tokens = state_pending_tokens(state, language, relative_key)
|
||||
queued_source_texts.update(
|
||||
leaf.source
|
||||
for leaf in iter_leaves(source)
|
||||
if leaf_token(leaf.path) in queued_tokens
|
||||
)
|
||||
for source_text in queued_source_texts:
|
||||
memory.pop(source_text, None)
|
||||
pending: list[
|
||||
tuple[Path, Path, Any, Any, list[Leaf], set[str], bool]
|
||||
] = []
|
||||
total_strings = 0
|
||||
missing_strings = 0
|
||||
|
||||
for source_path in files:
|
||||
relative = source_path.relative_to(source_root)
|
||||
relative_key = relative.as_posix()
|
||||
target_path = messages_root / language / relative
|
||||
source = file_info[relative_key][1]
|
||||
target = read_json(target_path)
|
||||
queued_tokens = state_pending_tokens(state, language, relative_key)
|
||||
leaves = pending_leaves(source, target, queued_tokens)
|
||||
total_strings += len(iter_leaves(source))
|
||||
missing_strings += len(leaves)
|
||||
schema_changed = (
|
||||
not schema_matches(source, target)
|
||||
or not literal_values_match(source, target)
|
||||
)
|
||||
if leaves or schema_changed:
|
||||
pending.append(
|
||||
(
|
||||
source_path,
|
||||
target_path,
|
||||
source,
|
||||
target,
|
||||
leaves,
|
||||
queued_tokens,
|
||||
schema_changed,
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
f"[{language}] {missing_strings}/{total_strings} strings pending "
|
||||
f"across {len(pending)} files; reusable translations: {len(memory)}"
|
||||
)
|
||||
if args.check or args.dry_run:
|
||||
continue
|
||||
if args.max_files:
|
||||
pending = pending[: args.max_files]
|
||||
|
||||
for index, (
|
||||
source_path,
|
||||
target_path,
|
||||
source,
|
||||
target,
|
||||
leaves,
|
||||
queued_tokens,
|
||||
schema_changed,
|
||||
) in enumerate(pending, 1):
|
||||
relative = source_path.relative_to(source_root)
|
||||
relative_key = relative.as_posix()
|
||||
suffix = " + schema sync" if schema_changed else ""
|
||||
print(
|
||||
f"[{language} {index}/{len(pending)}] {relative} "
|
||||
f"({len(leaves)} strings{suffix})",
|
||||
flush=True,
|
||||
)
|
||||
built, failures, calls, failed_tokens = translate_file(
|
||||
source,
|
||||
target,
|
||||
leaves,
|
||||
language,
|
||||
memory,
|
||||
translate,
|
||||
args,
|
||||
)
|
||||
if failures:
|
||||
total_failures += len(failures)
|
||||
print(
|
||||
f" {len(failures)} strings left untranslated ({calls} calls)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for failure in failures[:5]:
|
||||
print(f" - {failure}", file=sys.stderr)
|
||||
write_json(target_path, built)
|
||||
# The strings that failed stay pending, so the next run comes back
|
||||
# for them and leaves the ones that translated alone.
|
||||
set_state_pending_tokens(state, language, relative_key, failed_tokens)
|
||||
if failures:
|
||||
write_json(source_state_path, state)
|
||||
write_json(source_state_path, state)
|
||||
total_written += 1
|
||||
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
|
||||
|
||||
if args.check or args.dry_run:
|
||||
return 0
|
||||
print(f"Completed: {total_written} files written; {total_failures} failed strings.")
|
||||
return 1 if args.strict and total_failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check active terminal translations offline, without generating any text.
|
||||
|
||||
Validates formatting fields and literal commands/identifiers. Missing values
|
||||
can retain English fallback; --require-complete makes them errors. This does
|
||||
not establish the accuracy or fluency of the translated prose.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from build_translation_cache import (
|
||||
DEFAULT_LANGUAGES, STRUCTURAL_LITERAL_RE, extract_catalog_texts,
|
||||
extract_python_texts, extract_translate_texts,
|
||||
)
|
||||
from build_web_docs_i18n import read_json, validate_contract
|
||||
|
||||
|
||||
def active_sources(root: Path) -> list[str]:
|
||||
# Match build-translation-cache.yml, including launchers outside scripts/.
|
||||
extras = [root / name for name in ('menu', 'install_proxmenux.sh', 'install_proxmenux_beta.sh')]
|
||||
texts = extract_translate_texts(root / 'scripts', extras)
|
||||
texts += extract_translate_texts(root / 'oci/remote')
|
||||
texts += extract_python_texts([root / 'scripts', root / 'oci/src', root / 'oci/remote'])
|
||||
texts += extract_catalog_texts([root / 'oci/catalog'])
|
||||
return sorted({text for text in texts if '$' not in text and '`' not in text})
|
||||
|
||||
|
||||
def validate_message(source: str, target: str, *, title_separator: bool = False) -> None:
|
||||
validate_contract(source, target)
|
||||
for match in STRUCTURAL_LITERAL_RE.finditer(source):
|
||||
literal = match.group()
|
||||
# A page title may replace its decorative separator with a dash.
|
||||
# In command examples, a pipe remains a shell operator.
|
||||
if title_separator and literal == '|':
|
||||
continue
|
||||
# DNS hostnames are case-insensitive. Paths, field names and command
|
||||
# names are not, so do not case-fold those to hide a broken example.
|
||||
is_hostname = re.fullmatch(r'[A-Za-z0-9.-]+\.(?:com|org|net|io)', literal)
|
||||
if literal not in target and not (is_hostname and literal.casefold() in target.casefold()):
|
||||
raise ValueError(f'literal command or identifier changed: {literal}')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root', type=Path, default=Path('.'))
|
||||
parser.add_argument('--languages', default=','.join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument('--require-complete', action='store_true')
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
texts = active_sources(root)
|
||||
errors = 0
|
||||
for language in args.languages.split(','):
|
||||
if language not in DEFAULT_LANGUAGES:
|
||||
parser.error(f'Unsupported locale: {language}')
|
||||
catalog = read_json(root / 'lang' / f'{language}.json') or {}
|
||||
missing = 0
|
||||
for source in texts:
|
||||
target = catalog.get(source)
|
||||
if not isinstance(target, str) or not target.strip():
|
||||
missing += 1
|
||||
continue
|
||||
try:
|
||||
validate_message(source, target)
|
||||
except ValueError as error:
|
||||
print(f'[{language}] {source!r}: {error}')
|
||||
errors += 1
|
||||
print(f'[{language}] active={len(texts)} missing={missing}')
|
||||
if args.require_complete and missing:
|
||||
errors += 1
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check documentation translation structure and literal contracts offline.
|
||||
|
||||
Missing locales/leaves are allowed by default because the website falls back
|
||||
to English. Use --require-complete when validating a catalog completion PR.
|
||||
This checks data integrity, not translation fluency or linguistic accuracy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from build_web_docs_i18n import (
|
||||
DEFAULT_LANGUAGES,
|
||||
get_at_path,
|
||||
iter_leaves,
|
||||
literal_values_match,
|
||||
read_json,
|
||||
schema_matches,
|
||||
source_files,
|
||||
)
|
||||
from check_terminal_catalogs import validate_message
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
|
||||
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument("--require-complete", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = args.messages_dir.resolve()
|
||||
files = source_files(root / "en", ".")
|
||||
errors = 0
|
||||
for language in args.languages.split(","):
|
||||
language = language.strip()
|
||||
if language not in DEFAULT_LANGUAGES:
|
||||
parser.error(f"Unsupported locale: {language}")
|
||||
missing = checked = present_files = 0
|
||||
for file in files:
|
||||
relative = file.relative_to(root / "en")
|
||||
source = read_json(file)
|
||||
target = read_json(root / language / relative)
|
||||
if target is not None:
|
||||
present_files += 1
|
||||
if args.require_complete and not schema_matches(source, target):
|
||||
print(f"[{language}] {relative}: source/target schema differs")
|
||||
errors += 1
|
||||
for leaf in iter_leaves(source):
|
||||
localized = get_at_path(target, leaf.path)
|
||||
if not isinstance(localized, str) or (leaf.source.strip() and not localized.strip()):
|
||||
missing += 1
|
||||
continue
|
||||
try:
|
||||
validate_message(
|
||||
leaf.source, localized,
|
||||
title_separator=bool(
|
||||
leaf.path and leaf.path[0] == "meta"
|
||||
and leaf.path[-1] in ("title", "ogTitle", "twitterTitle")
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"[{language}] {relative}:{'.'.join(map(str, leaf.path))}: {exc}")
|
||||
errors += 1
|
||||
checked += 1
|
||||
if args.require_complete and not literal_values_match(source, target):
|
||||
print(f"[{language}] {relative}: literal commands or rendering metadata differ")
|
||||
errors += 1
|
||||
print(f"[{language}] files={present_files}/{len(files)} strings={checked} missing={missing}")
|
||||
if args.require_complete and missing:
|
||||
errors += 1
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,7 @@ def fetch_directory_json(api_url: str) -> list[dict]:
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError("GitHub API no devolvió una lista.")
|
||||
raise RuntimeError("The GitHub API did not return a list.")
|
||||
return data
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ def main() -> int:
|
||||
try:
|
||||
directory = fetch_directory_json(API_URL)
|
||||
except Exception as e:
|
||||
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
|
||||
print(f"ERROR: Could not read the JSON index: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cache: list[dict] = []
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OCI validation record: renders oci/VALIDATION.md from verification.json,
|
||||
checks the record, checks who a pull request credits, and records a reviewed
|
||||
validation report."""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VERIFICATION = ROOT / "oci/catalog/verification.json"
|
||||
INDEX = ROOT / "oci/catalog/index.json"
|
||||
OUTPUT = ROOT / "oci/VALIDATION.md"
|
||||
REVIEWERS = ROOT / ".github/oci-validation-reviewers"
|
||||
|
||||
REPO = "https://github.com/MacRimi/ProxMenux"
|
||||
BOARD = "https://github.com/users/MacRimi/projects/1"
|
||||
FORM = f"{REPO}/issues/new?template=oci-validation.yml"
|
||||
DISCUSSION = f"{REPO}/discussions/440"
|
||||
MAINTAINERS = {"macrimi"}
|
||||
LAB = "laboratory-validated"
|
||||
|
||||
USER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")
|
||||
REPORT_RE = re.compile(r"^https://github\.com/MacRimi/ProxMenux/"
|
||||
r"(issues/[0-9]+|discussions/[0-9]+(#discussioncomment-[0-9]+)?)$")
|
||||
ENTRY_KEYS = {"status", "community_tested"}
|
||||
COMMUNITY_KEYS = {"by", "date", "report", "digest", "scenario"}
|
||||
DIGEST_RE = re.compile(r"^[a-f0-9]{8,64}$")
|
||||
# Form fields that describe the tested scenario, in the order they are read.
|
||||
SCENARIO_FIELDS = ("Install mode", "Data storage", "Network", "GPU")
|
||||
NO_RESPONSE = "_No response_"
|
||||
|
||||
|
||||
def load(path):
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def dump(data):
|
||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def applications(index):
|
||||
return {item["id"]: item for item in index.get("applications", [])}
|
||||
|
||||
|
||||
def lab_validated(entry, item):
|
||||
# Same rule as the catalog: an application that cannot be installed is not verified.
|
||||
return entry.get("status") == LAB and bool(item.get("automatic_install_candidate"))
|
||||
|
||||
|
||||
def community(entry):
|
||||
tested = entry.get("community_tested")
|
||||
return tested if isinstance(tested, dict) and tested.get("by") else None
|
||||
|
||||
|
||||
def cell(text):
|
||||
return str(text).replace("|", "\\|").strip()
|
||||
|
||||
|
||||
def render(verification, index):
|
||||
apps = applications(index)
|
||||
entries = {app: entry for app, entry in verification.get("applications", {}).items()
|
||||
if app in apps and isinstance(entry, dict)}
|
||||
lab = sorted((app for app, entry in entries.items() if lab_validated(entry, apps[app])),
|
||||
key=lambda app: str(apps[app].get("title") or app).casefold())
|
||||
tested = sorted((app for app, entry in entries.items() if community(entry)),
|
||||
key=lambda app: str(apps[app].get("title") or app).casefold())
|
||||
visible = sum(1 for item in apps.values() if not item.get("hidden"))
|
||||
verified = len(set(lab) | set(tested))
|
||||
lines = [
|
||||
"# OCI application validation",
|
||||
"",
|
||||
"<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->",
|
||||
"",
|
||||
f"{verified} of {visible} applications in the OCI catalog have been tested for real: "
|
||||
f"{len(lab)} in the ProxMenux lab and {len(tested)} by the community. "
|
||||
"The OCI installer shows them as verified, with a ✓ in the lists.",
|
||||
"",
|
||||
"Each test describes the scenario that was run and names the image it ran on. It does not cover every possible "
|
||||
"configuration of the application, and a newer image has not been tested until someone reports it.",
|
||||
"",
|
||||
"## Taking part",
|
||||
"",
|
||||
"Any application that is not listed here is open for testing.",
|
||||
"",
|
||||
f"1. Check the [ProxMenux Roadmap]({BOARD}) to see which applications someone is already testing. "
|
||||
"With access to the board, create a card for the application and move it to In progress while you test it.",
|
||||
f"2. Test the application with the OCI manager and fill in the [OCI validation report]({FORM}). "
|
||||
"The report records the image and the exact scenario: install mode, storage, network and GPU.",
|
||||
"3. A reviewer reads the report and comments `/validated` on it. The application is then added to this list "
|
||||
"with your GitHub user and shown as verified in the OCI installer, and the report is closed.",
|
||||
"",
|
||||
"Reviewers are listed in [.github/oci-validation-reviewers](../.github/oci-validation-reviewers), and a "
|
||||
"reviewer does not validate their own report. A validation can also be added with a pull request that adds "
|
||||
"the entry to `oci/catalog/verification.json` and regenerates this file with "
|
||||
"`python3 .github/scripts/oci_validation.py render`; CI checks that the entry credits the author of the pull request.",
|
||||
"",
|
||||
"An application that cannot be validated for a reason outside the tester's hands — hardware nobody has, something "
|
||||
"only Docker provides, a fix still pending in ProxMenux — moves to Blocked on the board, with a line saying why "
|
||||
"and what would make it worth trying again.",
|
||||
"",
|
||||
f"Questions and ideas about OCI testing go in [discussion #440]({DISCUSSION}).",
|
||||
"",
|
||||
"## Tested by the community",
|
||||
"",
|
||||
]
|
||||
if tested:
|
||||
lines += ["| Application | Tested by | Date | Image | Scenario | Report |", "|---|---|---|---|---|---|"]
|
||||
for app in tested:
|
||||
entry = community(entries[app])
|
||||
report = f"[Report]({entry['report']})" if entry.get("report") else "—"
|
||||
image = f"`{entry['digest'][:12]}`" if entry.get("digest") else "—"
|
||||
lines.append(f"| {cell(apps[app].get('title') or app)} | [@{entry['by']}](https://github.com/{entry['by']}) "
|
||||
f"| {entry.get('date') or '—'} | {image} | {cell(entry.get('scenario') or '—')} | {report} |")
|
||||
else:
|
||||
lines.append("No application has been tested by the community yet.")
|
||||
lines += ["", "## Tested in the ProxMenux lab", ""]
|
||||
if lab:
|
||||
lines += ["| Application | Category |", "|---|---|"]
|
||||
for app in lab:
|
||||
lines.append(f"| {cell(apps[app].get('title') or app)} | {cell(apps[app].get('category_label') or '—')} |")
|
||||
else:
|
||||
lines.append("No application has been tested in the ProxMenux lab yet.")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def problems(verification, index, rendered=None, today=None):
|
||||
today = today or datetime.date.today()
|
||||
apps = applications(index)
|
||||
found = []
|
||||
entries = verification.get("applications") if isinstance(verification, dict) else None
|
||||
if not isinstance(entries, dict):
|
||||
return ["verification.json must hold an \"applications\" object"]
|
||||
for app, entry in entries.items():
|
||||
where = f"verification.json: {app}"
|
||||
if app not in apps:
|
||||
found.append(f"{where}: not an application of the OCI catalog")
|
||||
if not isinstance(entry, dict) or not entry:
|
||||
found.append(f"{where}: the entry must be a non-empty object")
|
||||
continue
|
||||
if set(entry) - ENTRY_KEYS:
|
||||
found.append(f"{where}: unknown keys {sorted(set(entry) - ENTRY_KEYS)}")
|
||||
if "status" in entry and entry["status"] != LAB:
|
||||
found.append(f"{where}: status can only be \"{LAB}\"")
|
||||
if "community_tested" not in entry:
|
||||
continue
|
||||
tested = entry["community_tested"]
|
||||
if not isinstance(tested, dict):
|
||||
found.append(f"{where}: community_tested must be an object")
|
||||
continue
|
||||
if set(tested) - COMMUNITY_KEYS:
|
||||
found.append(f"{where}: unknown community_tested keys {sorted(set(tested) - COMMUNITY_KEYS)}")
|
||||
if not USER_RE.fullmatch(str(tested.get("by", ""))):
|
||||
found.append(f"{where}: \"by\" must be a GitHub user name")
|
||||
try:
|
||||
date = datetime.date.fromisoformat(str(tested.get("date", "")))
|
||||
if date > today + datetime.timedelta(days=1):
|
||||
found.append(f"{where}: the date is in the future")
|
||||
except ValueError:
|
||||
found.append(f"{where}: \"date\" must be YYYY-MM-DD")
|
||||
if "report" in tested and not REPORT_RE.fullmatch(str(tested["report"])):
|
||||
found.append(f"{where}: \"report\" must link to an issue or discussion of MacRimi/ProxMenux")
|
||||
if "digest" in tested and not DIGEST_RE.fullmatch(str(tested["digest"])):
|
||||
found.append(f"{where}: \"digest\" must be 8 to 64 lowercase hex characters of the image digest")
|
||||
if "scenario" in tested and (not isinstance(tested["scenario"], str) or len(tested["scenario"]) > 300):
|
||||
found.append(f"{where}: \"scenario\" must be text of at most 300 characters")
|
||||
if rendered is not None and rendered != render(verification, index):
|
||||
found.append("oci/VALIDATION.md is out of date: run python3 .github/scripts/oci_validation.py render")
|
||||
return found
|
||||
|
||||
|
||||
def author_problems(base, head, author):
|
||||
"""A pull request only adds or changes community tests credited to its author."""
|
||||
if author.casefold() in MAINTAINERS:
|
||||
return []
|
||||
found = []
|
||||
before = base.get("applications", {}) if isinstance(base, dict) else {}
|
||||
after = head.get("applications", {})
|
||||
for app in sorted(set(before) | set(after)):
|
||||
old = before.get(app) if isinstance(before.get(app), dict) else {}
|
||||
new = after.get(app) if isinstance(after.get(app), dict) else {}
|
||||
if old.get("status") != new.get("status"):
|
||||
found.append(f"{app}: the ProxMenux lab status is changed by the maintainer only")
|
||||
if old.get("community_tested") == new.get("community_tested"):
|
||||
continue
|
||||
credited = community(new)
|
||||
if not credited:
|
||||
found.append(f"{app}: a community test is removed by the maintainer only")
|
||||
elif str(credited["by"]).casefold() != author.casefold():
|
||||
found.append(f"{app}: credits @{credited['by']}, but the pull request is from @{author}")
|
||||
return found
|
||||
|
||||
|
||||
def parse_form(body):
|
||||
"""Issue form answers, keyed by the field label."""
|
||||
fields, label, lines = {}, None, []
|
||||
for line in (body or "").replace("\r\n", "\n").split("\n"):
|
||||
if line.startswith("### "):
|
||||
if label is not None:
|
||||
fields[label] = "\n".join(lines).strip()
|
||||
label, lines = line[4:].strip(), []
|
||||
elif label is not None:
|
||||
lines.append(line)
|
||||
if label is not None:
|
||||
fields[label] = "\n".join(lines).strip()
|
||||
return {key: ("" if value == NO_RESPONSE else value) for key, value in fields.items()}
|
||||
|
||||
|
||||
def resolve(name, index):
|
||||
wanted = " ".join(name.split()).casefold()
|
||||
matches = [item["id"] for item in index.get("applications", [])
|
||||
if wanted in (str(item["id"]).casefold(), " ".join(str(item.get("title") or "").split()).casefold())]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def image_digest(text):
|
||||
"""The digest as the Monitor shows it (b1f339cf) or in full (sha256:b1f3...)."""
|
||||
value = str(text or "").strip().lower()
|
||||
value = value.split("·")[-1].strip()
|
||||
value = value[7:] if value.startswith("sha256:") else value
|
||||
return value if DIGEST_RE.fullmatch(value) else None
|
||||
|
||||
|
||||
def scenario(fields):
|
||||
parts = [fields.get(name, "") for name in SCENARIO_FIELDS]
|
||||
if fields.get("Proxmox VE version"):
|
||||
parts.append(f"Proxmox VE {fields['Proxmox VE version']}")
|
||||
if fields.get("Image version"):
|
||||
parts.append(f"image {fields['Image version']}")
|
||||
text = " · ".join(part.strip() for part in parts if part and part.strip())
|
||||
if fields.get("Result") == "Works with problems":
|
||||
text = f"Works with problems: {text}"
|
||||
return text[:300]
|
||||
|
||||
|
||||
def reviewers(text):
|
||||
"""User names of the reviewer list, one per line; # starts a comment."""
|
||||
names = {line.split("#", 1)[0].strip() for line in text.splitlines()}
|
||||
return {name.casefold() for name in names if USER_RE.fullmatch(name)}
|
||||
|
||||
|
||||
def record(event, verification, index, today, allowed=frozenset()):
|
||||
"""Returns (verification, app id, message); raises ValueError with the reason to refuse.
|
||||
|
||||
A label can only be added by someone with write access to the repository.
|
||||
A /validated comment is accepted from the reviewer list, never from the
|
||||
report's own author unless it is the maintainer."""
|
||||
issue = event.get("issue") or {}
|
||||
labels = {label.get("name") for label in issue.get("labels", [])}
|
||||
if "oci-validation" not in labels:
|
||||
raise ValueError("This issue is not an OCI validation report.")
|
||||
author = str((issue.get("user") or {}).get("login", ""))
|
||||
report = str(issue.get("html_url", ""))
|
||||
if not USER_RE.fullmatch(author) or not REPORT_RE.fullmatch(report):
|
||||
raise ValueError("The report author or link could not be read.")
|
||||
reviewer = str(((event.get("comment") or {}).get("user") or {}).get("login", ""))
|
||||
if event.get("comment") is not None:
|
||||
if reviewer.casefold() not in allowed:
|
||||
raise ValueError(f"@{reviewer} is not in the list of OCI validation reviewers "
|
||||
"(.github/oci-validation-reviewers), so the report is not recorded.")
|
||||
if reviewer.casefold() == author.casefold() and reviewer.casefold() not in MAINTAINERS:
|
||||
raise ValueError("A report is validated by a reviewer other than its author.")
|
||||
fields = parse_form(issue.get("body"))
|
||||
name = fields.get("Application", "")
|
||||
app = resolve(name, index)
|
||||
if app is None:
|
||||
raise ValueError(f"\"{name}\" does not match one application of the OCI catalog. "
|
||||
"Edit the Application field with its name or ID as shown in the catalog, then add the label again.")
|
||||
if fields.get("Result") == "Does not work":
|
||||
raise ValueError("The report says the application does not work, so it is not recorded as verified.")
|
||||
digest = image_digest(fields.get("Image digest"))
|
||||
if digest is None:
|
||||
raise ValueError("The Image digest field does not hold an image digest. Edit it with the digest shown in the "
|
||||
"App tab of the Monitor, for example b1f339cf, then add the label again.")
|
||||
data = json.loads(json.dumps(verification))
|
||||
entries = data.setdefault("applications", {})
|
||||
entry = entries.setdefault(app, {})
|
||||
previous = community(entry)
|
||||
entry["community_tested"] = {"by": author, "date": today.isoformat(), "report": report,
|
||||
"digest": digest, "scenario": scenario(fields)}
|
||||
data["applications"] = dict(sorted(entries.items()))
|
||||
title = applications(index)[app].get("title") or app
|
||||
validated = f" Validated by @{reviewer}." if reviewer else ""
|
||||
message = (f"Thank you, @{author}! {title} is now recorded as verified.{validated} "
|
||||
f"It shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md]({REPO}/blob/develop/oci/VALIDATION.md).")
|
||||
if previous and previous.get("by") != author:
|
||||
message += f" This report replaces the previous one from @{previous['by']}."
|
||||
return data, app, message
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
commands.add_parser("render", help="Write oci/VALIDATION.md")
|
||||
commands.add_parser("check", help="Check verification.json and that oci/VALIDATION.md is current")
|
||||
authors = commands.add_parser("authors", help="Check who a pull request credits")
|
||||
authors.add_argument("--base", required=True, help="verification.json of the base branch; may be missing")
|
||||
authors.add_argument("--author", required=True)
|
||||
rec = commands.add_parser("record", help="Record a reviewed validation report")
|
||||
rec.add_argument("--event", required=True)
|
||||
rec.add_argument("--message", required=True, help="File that receives the reply for the issue")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
verification, index = load(VERIFICATION), load(INDEX)
|
||||
if args.command == "render":
|
||||
OUTPUT.write_text(render(verification, index), encoding="utf-8")
|
||||
return 0
|
||||
if args.command == "check":
|
||||
rendered = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
|
||||
found = problems(verification, index, rendered)
|
||||
elif args.command == "authors":
|
||||
base = load(args.base) if Path(args.base).exists() and Path(args.base).stat().st_size else {}
|
||||
found = author_problems(base, verification, args.author)
|
||||
else:
|
||||
try:
|
||||
allowed = reviewers(REVIEWERS.read_text(encoding="utf-8")) if REVIEWERS.exists() else frozenset()
|
||||
data, app, message = record(load(args.event), verification, index, datetime.date.today(), allowed)
|
||||
except ValueError as error:
|
||||
Path(args.message).write_text(str(error) + "\n", encoding="utf-8")
|
||||
return 2
|
||||
VERIFICATION.write_text(dump(data), encoding="utf-8")
|
||||
OUTPUT.write_text(render(data, index), encoding="utf-8")
|
||||
Path(args.message).write_text(message + "\n", encoding="utf-8")
|
||||
if os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
||||
output.write(f"app={app}\n")
|
||||
return 0
|
||||
for problem in found:
|
||||
print(f"::error::{problem}")
|
||||
return 1 if found else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only the independently qualified, host-independent fixtures.
|
||||
|
||||
No filesystem discovery widens this manifest. Run from the repository root;
|
||||
Python and Node have separate CI jobs so either failure blocks the check.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"tests/test_host_update_jobs.py",
|
||||
"tests/test_host_update_transport.py",
|
||||
"tests/test_host_update_shell.py",
|
||||
"tests/test_host_update_auth.py",
|
||||
"tests/test_kernel_reboot.py",
|
||||
"tests/test_reboot_health_refresh.py",
|
||||
"tests/test_host_update_controls.py",
|
||||
"tests/test_host_update_packaging.py",
|
||||
"tests/test_host_update_version.py",
|
||||
"tests/test_lxc_update_jobs.py",
|
||||
"tests/test_post_install_i18n.py",
|
||||
"tests/test_remaining_cli_i18n.py",
|
||||
"AppImage/scripts/tests/test_pbs_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links.py",
|
||||
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
|
||||
"AppImage/scripts/tests/test_local_app_icons.py",
|
||||
"AppImage/scripts/tests/test_docker_app_discovery.py",
|
||||
"AppImage/scripts/tests/test_native_app_discovery.py",
|
||||
"tests/test_audit_presentation.py",
|
||||
"tests/test_audit_safety_wording.py",
|
||||
"tests/test_audit_policy.py",
|
||||
"tests/test_audit_report.py",
|
||||
"tests/test_audit_catalog.py",
|
||||
"tests/storage/test_nvme_status_message.py",
|
||||
"tests/test_fastfetch_config_generation.py",
|
||||
"tests/test_notification_active_payloads.py",
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/test_host_update_ui.cjs",
|
||||
"tests/test_reboot_health_refresh.cjs",
|
||||
"tests/test_host_update_controls.cjs",
|
||||
"tests/test_reboot_recovery.cjs",
|
||||
"tests/test_proxmenux_version_row.cjs",
|
||||
"tests/test_lxc_update_jobs.cjs",
|
||||
"tests/test_api_import_boundaries.cjs",
|
||||
"tests/test_api_errors.cjs",
|
||||
"tests/test_optimization_labels.cjs",
|
||||
"tests/test_optimization_source.cjs",
|
||||
"tests/lxc_updates/test_app_editor_errors.cjs",
|
||||
"tests/test_oci_update_cancellation.cjs",
|
||||
"tests/test_host_backup_messages.cjs",
|
||||
"tests/test_pbs_i18n.cjs",
|
||||
"tests/test_monitor_links_i18n.cjs",
|
||||
"tests/lxc_updates/test_local_app_icons_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_delegated_ui.cjs",
|
||||
"tests/test_audit_diagnostic_document.cjs",
|
||||
"tests/test_audit_policy.cjs",
|
||||
"tests/test_audit_presentation.cjs",
|
||||
"tests/test_audit_safety_wording.cjs",
|
||||
"tests/test_audit_summary.cjs",
|
||||
"tests/test_backup_archives_empty.cjs",
|
||||
"tests/test_backup_destination_messages.cjs",
|
||||
"tests/test_borg_ssh_guidance.cjs",
|
||||
"tests/test_storage_messages.cjs",
|
||||
"tests/test_health_text.cjs",
|
||||
"tests/test_overview_chart_axis_gutters.cjs",
|
||||
)
|
||||
|
||||
# unittest's CLI accepts a missing pattern as a successful zero-test run.
|
||||
# Load the exact file in a fresh interpreter and reject zero tests and skips.
|
||||
PYTHON_RUN = """import sys, unittest
|
||||
folder, filename = sys.argv[1:]
|
||||
suite = unittest.TestLoader().discover(start_dir=folder, pattern=filename)
|
||||
count = suite.countTestCases()
|
||||
if count == 0:
|
||||
raise SystemExit('No tests discovered: ' + folder + '/' + filename)
|
||||
print('DISCOVERED=' + str(count), flush=True)
|
||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
sys.exit(0 if result.wasSuccessful() and result.testsRun == count and not result.skipped else 1)
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--lane", required=True, choices=("python", "node"))
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
files = PYTHON_FILES if args.lane == "python" else NODE_FILES
|
||||
if not files or Path.cwd().resolve() != root:
|
||||
parser.error("nonempty manifest and repository-root working directory required")
|
||||
for path in files:
|
||||
if not (root / path).is_file():
|
||||
print(f"Missing qualified test: {path}", file=sys.stderr)
|
||||
return 1
|
||||
if args.lane == "node" and not (root / "AppImage/node_modules/typescript").is_dir():
|
||||
print("Install the locked offline-node dependencies first", file=sys.stderr)
|
||||
return 1
|
||||
for path in files:
|
||||
print(f"RUN {path}", flush=True)
|
||||
if args.lane == "python":
|
||||
folder, filename = str(Path(path).parent), Path(path).name
|
||||
command = [sys.executable, "-I", "-B", "-c", PYTHON_RUN, folder, filename]
|
||||
with tempfile.TemporaryDirectory(prefix="qualified-offline-") as scratch:
|
||||
environment = os.environ.copy()
|
||||
environment["TMPDIR"] = scratch
|
||||
result = subprocess.run(command, cwd=root, env=environment, check=False)
|
||||
else:
|
||||
command = ["node", path]
|
||||
result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False)
|
||||
if result.returncode != 0:
|
||||
print(f"FAIL {path}: exit {result.returncode}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"PASS {path}", flush=True)
|
||||
print(f"PASS {args.lane}: {len(files)} qualified files", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,558 @@
|
||||
{
|
||||
"pin": "dede876f0b92f8a571325e96ed7591b30a85368e",
|
||||
"fallback_indices": [
|
||||
9,
|
||||
10,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
34,
|
||||
43,
|
||||
44,
|
||||
45,
|
||||
58,
|
||||
59
|
||||
],
|
||||
"locales": {
|
||||
"es": [
|
||||
"Listar todos los nodos Proxmox",
|
||||
"Listar VM en un nodo",
|
||||
"Listar contenedores LXC en un nodo",
|
||||
"Listar almacenamiento en un nodo",
|
||||
"Listar interfaces de red",
|
||||
"Listar todas las máquinas virtuales QEMU/KVM",
|
||||
"Iniciar una máquina virtual",
|
||||
"Detener una máquina virtual",
|
||||
"Apagar una máquina virtual de forma segura",
|
||||
"Mostrar estado de VM",
|
||||
"Mostrar configuración de VM",
|
||||
"Crear un snapshot de VM",
|
||||
"Listar todos los contenedores LXC",
|
||||
"Iniciar un contenedor LXC",
|
||||
"Detener un contenedor LXC",
|
||||
"Abrir una consola de contenedor LXC",
|
||||
"Mostrar configuración del contenedor",
|
||||
"Mostrar estado de almacenamiento",
|
||||
"Enumerar el contenido del almacenamiento",
|
||||
"Probar el rendimiento del sistema Proxmox",
|
||||
"Mostrar la versión de Proxmox VE",
|
||||
"Verificar el estado del clúster",
|
||||
"Mostrar el estado del clúster",
|
||||
"Enumerar los nodos del clúster",
|
||||
"Mostrar el estado del grupo ZFS",
|
||||
"Enumerar todos los grupos ZFS",
|
||||
"Enumerar todo Conjuntos de datos ZFS",
|
||||
"Enumerar todos los archivos con detalles",
|
||||
"Cambiar directorio",
|
||||
"Crear un directorio",
|
||||
"Eliminar un directorio de forma recursiva",
|
||||
"Copiar archivos o directorios",
|
||||
"Mover o cambiar el nombre de archivos",
|
||||
"Mostrar el contenido del archivo",
|
||||
"Buscar un patrón en un archivo",
|
||||
"Buscar archivos por nombre",
|
||||
"Cambiar permisos de archivos",
|
||||
"Cambiar el propietario del archivo",
|
||||
"Extraer un archivo tar.gz",
|
||||
"Crear un tar.gz archivo",
|
||||
"Mostrar uso del disco",
|
||||
"Mostrar tamaños de directorio",
|
||||
"Mostrar uso de memoria",
|
||||
"Mostrar procesos en ejecución",
|
||||
"Buscar un proceso en ejecución",
|
||||
"Forzar la eliminación de un proceso",
|
||||
"Verificar el estado del servicio",
|
||||
"Iniciar un servicio",
|
||||
"Detener un servicio",
|
||||
"Reiniciar un servicio",
|
||||
"Actualizar paquetes de Debian/Ubuntu",
|
||||
"Instalar un paquete de Debian/Ubuntu",
|
||||
"Eliminar un paquete",
|
||||
"Listar contenedores en ejecución",
|
||||
"Listar Docker imágenes",
|
||||
"Abrir un shell en un contenedor",
|
||||
"Mostrar direcciones IP",
|
||||
"Probar conectividad de red",
|
||||
"Obtener encabezados HTTP",
|
||||
"Descargar un archivo",
|
||||
"Conectarse a través de SSH",
|
||||
"Copiar un archivo a través de SSH",
|
||||
"Seguir un archivo de registro en tiempo real",
|
||||
"Mostrar historial de comandos",
|
||||
"Borrar la pantalla del terminal"
|
||||
],
|
||||
"de": [
|
||||
"Alle Proxmox-Knoten auflisten",
|
||||
"VMs auf einem Knoten auflisten",
|
||||
"LXC-Container auf einem Knoten auflisten",
|
||||
"Speicher auf einem Knoten auflisten",
|
||||
"Netzwerkschnittstellen auflisten",
|
||||
"Alle virtuellen QEMU/KVM-Maschinen auflisten",
|
||||
"Eine virtuelle Maschine starten",
|
||||
"Eine virtuelle Maschine stoppen",
|
||||
"Eine virtuelle Maschine sicher herunterfahren",
|
||||
"VM-Status anzeigen",
|
||||
"VM-Konfiguration anzeigen",
|
||||
"Einen VM-Snapshot erstellen",
|
||||
"Alle LXC-Container auflisten",
|
||||
"Einen LXC-Container starten",
|
||||
"Einen LXC-Container stoppen",
|
||||
"Eine LXC-Containerkonsole öffnen",
|
||||
"Containerkonfiguration anzeigen",
|
||||
"Speicherstatus anzeigen",
|
||||
"Speicherinhalte auflisten",
|
||||
"Proxmox-Systemleistung testen",
|
||||
"Proxmox VE-Version anzeigen",
|
||||
"Clusterstatus prüfen",
|
||||
"Clusterstatus anzeigen",
|
||||
"Clusterknoten auflisten",
|
||||
"ZFS-Poolstatus anzeigen",
|
||||
"Alle ZFS-Pools auflisten",
|
||||
"Alle ZFS-Datensätze auflisten",
|
||||
"Alle Dateien mit Details auflisten",
|
||||
"Verzeichnis ändern",
|
||||
"Verzeichnis erstellen",
|
||||
"Verzeichnis rekursiv entfernen",
|
||||
"Dateien oder Verzeichnisse kopieren",
|
||||
"Dateien verschieben oder umbenennen",
|
||||
"Dateiinhalte anzeigen",
|
||||
"Nach einem Muster in einer Datei suchen",
|
||||
"Dateien nach Namen suchen",
|
||||
"Dateiberechtigungen ändern",
|
||||
"Dateieigentümer ändern",
|
||||
"Tar.gz-Archiv extrahieren",
|
||||
"Tar.gz-Archiv erstellen",
|
||||
"Laufwerksnutzung anzeigen",
|
||||
"Verzeichnisgrößen anzeigen",
|
||||
"Speichernutzung anzeigen",
|
||||
"Laufende Prozesse anzeigen",
|
||||
"Laufenden Prozess suchen",
|
||||
"Prozess beenden",
|
||||
"Dienststatus prüfen",
|
||||
"Dienst starten",
|
||||
"Dienst stoppen",
|
||||
"Dienst neu starten",
|
||||
"Debian/Ubuntu-Pakete aktualisieren",
|
||||
"Debian/Ubuntu-Paket installieren",
|
||||
"Paket entfernen",
|
||||
"Laufende Container auflisten",
|
||||
"Docker-Images auflisten",
|
||||
"Eine Shell in einem Container öffnen",
|
||||
"IP-Adressen anzeigen",
|
||||
"Netzwerkkonnektivität testen",
|
||||
"HTTP-Header abrufen",
|
||||
"Eine Datei herunterladen",
|
||||
"Über SSH verbinden",
|
||||
"Eine Datei über SSH kopieren",
|
||||
"Eine Protokolldatei in Echtzeit verfolgen",
|
||||
"Befehlsverlauf anzeigen",
|
||||
"Terminalbildschirm löschen"
|
||||
],
|
||||
"fr": [
|
||||
"Liste de tous les nœuds Proxmox",
|
||||
"Liste des machines virtuelles sur un nœud",
|
||||
"Liste des conteneurs LXC sur un nœud",
|
||||
"Liste du stockage sur un nœud",
|
||||
"Liste des interfaces réseau",
|
||||
"Liste de toutes les machines virtuelles QEMU/KVM",
|
||||
"Démarrer une machine virtuelle",
|
||||
"Arrêter une machine virtuelle",
|
||||
"Arrêter une machine virtuelle en toute sécurité",
|
||||
"Afficher l'état de la VM",
|
||||
"Afficher la VM configuration",
|
||||
"Créer un instantané de VM",
|
||||
"Liste de tous les conteneurs LXC",
|
||||
"Démarrer un conteneur LXC",
|
||||
"Arrêter un conteneur LXC",
|
||||
"Ouvrir une console de conteneur LXC",
|
||||
"Afficher la configuration du conteneur",
|
||||
"Afficher l'état du stockage",
|
||||
"Liste du contenu du stockage",
|
||||
"Tester les performances du système Proxmox",
|
||||
"Afficher la version Proxmox VE",
|
||||
"Vérifier l'état du cluster",
|
||||
"Afficher l'état du cluster",
|
||||
"Liste des nœuds du cluster",
|
||||
"Afficher l'état du pool ZFS",
|
||||
"Liste de tous les pools ZFS",
|
||||
"Liste de tous les ensembles de données ZFS",
|
||||
"Liste de tous les fichiers avec détails",
|
||||
"Changer de répertoire",
|
||||
"Créer un répertoire",
|
||||
"Supprimer un répertoire de manière récursive",
|
||||
"Copier des fichiers ou des répertoires",
|
||||
"Déplacer ou renommer des fichiers",
|
||||
"Afficher le contenu du fichier",
|
||||
"Rechercher un modèle dans un fichier",
|
||||
"Rechercher des fichiers par nom",
|
||||
"Modifier les autorisations du fichier",
|
||||
"Modifier le propriétaire du fichier",
|
||||
"Extraire une archive tar.gz",
|
||||
"Créer une archive tar.gz",
|
||||
"Afficher l'utilisation du disque",
|
||||
"Afficher la taille des répertoires",
|
||||
"Afficher l'utilisation de la mémoire",
|
||||
"Afficher les processus en cours d'exécution",
|
||||
"Trouver un processus en cours d'exécution",
|
||||
"Forcer l'arrêt d'un processus",
|
||||
"Vérifier le service\". statut",
|
||||
"Démarrer un service",
|
||||
"Arrêter un service",
|
||||
"Redémarrer un service",
|
||||
"Mettre à jour les packages Debian/Ubuntu",
|
||||
"Installer un package Debian/Ubuntu",
|
||||
"Supprimer un package",
|
||||
"Liste des conteneurs en cours d'exécution",
|
||||
"Liste des images Docker",
|
||||
"Ouvrir un shell dans un conteneur",
|
||||
"Afficher les adresses IP",
|
||||
"Tester la connectivité réseau",
|
||||
"Obtenir les en-têtes HTTP",
|
||||
"Télécharger un fichier",
|
||||
"Connecter via SSH",
|
||||
"Copier un fichier via SSH",
|
||||
"Suivre un fichier journal en temps réel",
|
||||
"Afficher l'historique des commandes",
|
||||
"Effacer l'écran du terminal"
|
||||
],
|
||||
"it": [
|
||||
"Elenca tutti i nodi Proxmox",
|
||||
"Elenca VM su un nodo",
|
||||
"Elenca container LXC su un nodo",
|
||||
"Elenca storage su un nodo",
|
||||
"Elenca interfacce di rete",
|
||||
"Elenca tutte le VM QEMU/KVM",
|
||||
"Avvia una VM",
|
||||
"Arresta una VM",
|
||||
"Spegni una VM in modo ordinato",
|
||||
"Mostra stato VM",
|
||||
"Mostra configurazione VM",
|
||||
"Crea snapshot VM",
|
||||
"Elenca tutti i container LXC",
|
||||
"Avvia un container LXC",
|
||||
"Arresta un container LXC",
|
||||
"Apri una shell in un container LXC",
|
||||
"Mostra configurazione container",
|
||||
"Mostra stato storage",
|
||||
"Elenca contenuto storage",
|
||||
"Verifica prestazioni del sistema Proxmox",
|
||||
"Mostra versione Proxmox VE",
|
||||
"Mostra stato servizio pve-cluster",
|
||||
"Mostra stato cluster",
|
||||
"Elenca nodi cluster",
|
||||
"Mostra stato pool ZFS",
|
||||
"Elenca tutti i pool ZFS",
|
||||
"Elenca filesystem e volumi ZFS",
|
||||
"Elenca tutti i file con dettagli",
|
||||
"Cambia directory",
|
||||
"Crea una directory",
|
||||
"Rimuovi una directory ricorsivamente",
|
||||
"Copia file",
|
||||
"Sposta o rinomina file",
|
||||
"Visualizza contenuto file",
|
||||
"Cerca un pattern in un file",
|
||||
"Trova file per nome",
|
||||
"Modifica permessi file",
|
||||
"Cambia proprietario file",
|
||||
"Estrai un archivio tar.gz",
|
||||
"Crea un archivio tar.gz",
|
||||
"Mostra utilizzo disco",
|
||||
"Mostra dimensioni directory",
|
||||
"Mostra utilizzo memoria",
|
||||
"Mostra processi in esecuzione",
|
||||
"Trova un processo in esecuzione",
|
||||
"Termina forzatamente un processo",
|
||||
"Controlla stato servizio",
|
||||
"Avvia un servizio",
|
||||
"Arresta un servizio",
|
||||
"Riavvia un servizio",
|
||||
"Aggiorna pacchetti Debian/Ubuntu",
|
||||
"Installa un pacchetto Debian/Ubuntu",
|
||||
"Rimuovi un pacchetto",
|
||||
"Elenca i container in esecuzione",
|
||||
"Elenca immagini Docker",
|
||||
"Apri una shell in un container",
|
||||
"Mostra indirizzi IP",
|
||||
"Verifica connettività di rete",
|
||||
"Ottieni intestazioni HTTP",
|
||||
"Scarica un file",
|
||||
"Connettiti tramite SSH",
|
||||
"Copia un file tramite SSH",
|
||||
"Segui un file di log in tempo reale",
|
||||
"Mostra cronologia comandi",
|
||||
"Pulisci lo schermo del terminale"
|
||||
],
|
||||
"pt": [
|
||||
"Listar todos os nós Proxmox",
|
||||
"Listar VMs em um nó",
|
||||
"Listar contêineres LXC em um nó",
|
||||
"Listar armazenamento em um nó",
|
||||
"Listar interfaces de rede",
|
||||
"Listar todas as máquinas virtuais QEMU/KVM",
|
||||
"Iniciar uma máquina virtual",
|
||||
"Parar uma máquina virtual",
|
||||
"Desligar uma máquina virtual com segurança",
|
||||
"Mostrar status da VM",
|
||||
"Mostrar configuração da VM",
|
||||
"Criar um instantâneo de VM",
|
||||
"Listar todos os LXC containers",
|
||||
"Iniciar um contêiner LXC",
|
||||
"Parar um contêiner LXC",
|
||||
"Abrir um console de contêiner LXC",
|
||||
"Mostrar configuração do contêiner",
|
||||
"Mostrar status de armazenamento",
|
||||
"Listar conteúdo de armazenamento",
|
||||
"Testar desempenho do sistema Proxmox",
|
||||
"Mostrar a versão Proxmox VE",
|
||||
"Verificar status do cluster",
|
||||
"Mostrar status do cluster",
|
||||
"Listar nós do cluster",
|
||||
"Mostrar status do pool ZFS",
|
||||
"Listar todos os pools ZFS",
|
||||
"Listar todos os conjuntos de dados ZFS",
|
||||
"Listar todos os arquivos com detalhes",
|
||||
"Alterar diretório",
|
||||
"Criar um diretório",
|
||||
"Remover um diretório recursivamente",
|
||||
"Copiar arquivos ou diretórios",
|
||||
"Mover ou renomear arquivos",
|
||||
"Exibir conteúdo do arquivo",
|
||||
"Pesquisar um padrão em um arquivo",
|
||||
"Encontrar arquivos por nome",
|
||||
"Alterar permissões de arquivo",
|
||||
"Alterar o proprietário do arquivo",
|
||||
"Extrair um arquivo tar.gz",
|
||||
"Criar um arquivo tar.gz",
|
||||
"Mostrar uso de disco",
|
||||
"Mostrar tamanhos de diretório",
|
||||
"Mostrar uso de memória",
|
||||
"Mostrar processos em execução",
|
||||
"Encontrar um processo em execução",
|
||||
"Forçar a eliminação de um processo",
|
||||
"Verificar status do serviço",
|
||||
"Iniciar um serviço",
|
||||
"Parar um serviço",
|
||||
"Reiniciar um serviço",
|
||||
"Atualizar pacotes Debian/Ubuntu",
|
||||
"Instalar um pacote Debian/Ubuntu",
|
||||
"Remover um pacote",
|
||||
"Listar contêineres em execução",
|
||||
"Listar imagens do Docker",
|
||||
"Abrir um shell em um contêiner",
|
||||
"Mostrar endereços IP",
|
||||
"Testar conectividade de rede",
|
||||
"Obter cabeçalhos HTTP",
|
||||
"Baixar um arquivo",
|
||||
"Conectar através de SSH",
|
||||
"Copiar um arquivo através de SSH",
|
||||
"Seguir um arquivo de log em tempo real",
|
||||
"Mostrar histórico de comandos",
|
||||
"Limpar a tela do terminal"
|
||||
],
|
||||
"sv": [
|
||||
"Lista alla Proxmox-noder",
|
||||
"Lista virtuella datorer på en nod",
|
||||
"Lista LXC-behållare på en nod",
|
||||
"Lista lagringsutrymme på en nod",
|
||||
"Lista nätverksgränssnitt",
|
||||
"Lista alla virtuella QEMU/KVM-maskiner",
|
||||
"Starta en virtuell maskin",
|
||||
"Stoppa en virtuell maskin",
|
||||
"Stäng av en virtuell maskin på ett säkert sätt",
|
||||
"Show VM status",
|
||||
"Show VM configuration",
|
||||
"Skapa en VM Snapshot",
|
||||
"Lista alla LXC-behållare",
|
||||
"Starta en LXC-behållare",
|
||||
"Stoppa en LXC-behållare",
|
||||
"Öppna en LXC-containerkonsol",
|
||||
"Visa behållarkonfiguration",
|
||||
"Visa lagringsstatus",
|
||||
"Lista lagringsinnehåll",
|
||||
"Test Proxmox system performance",
|
||||
"Show the Proxmox VE version",
|
||||
"Check cluster status",
|
||||
"Show cluster status",
|
||||
"Lista klusternoder",
|
||||
"Visa ZFS-poolstatus",
|
||||
"Lista alla ZFS-pooler",
|
||||
"Lista alla ZFS-datauppsättningar",
|
||||
"Lista alla filer med detaljer",
|
||||
"Ändra katalog",
|
||||
"Skapa en katalog",
|
||||
"Ta bort en katalog rekursivt",
|
||||
"Copy files or directories",
|
||||
"Move or rename files",
|
||||
"Display file contents",
|
||||
"Search for a pattern in a file",
|
||||
"Hitta filer efter namn",
|
||||
"Ändra filbehörigheter",
|
||||
"Ändra filägare",
|
||||
"Extrahera ett tar.gz-arkiv",
|
||||
"Skapa ett tar.gz-arkiv",
|
||||
"Visa diskanvändning",
|
||||
"Visa katalogstorlekar",
|
||||
"Visa minnesanvändning",
|
||||
"Show running processes",
|
||||
"Find a running process",
|
||||
"Force-kill a process",
|
||||
"Kontrollera tjänsten status",
|
||||
"Starta en tjänst",
|
||||
"Stoppa en tjänst",
|
||||
"Starta om en tjänst",
|
||||
"Uppdatera Debian/Ubuntu-paket",
|
||||
"Installera ett Debian/Ubuntu-paket",
|
||||
"Ta bort ett paket",
|
||||
"Lista behållare som körs",
|
||||
"Lista Docker-avbildningar",
|
||||
"Öppna ett skal i en behållare",
|
||||
"Visa IP-adresser",
|
||||
"Testa en nätverksanslutning",
|
||||
"Get HTTP headers",
|
||||
"Download a file",
|
||||
"Anslut via SSH",
|
||||
"Kopiera en fil via SSH",
|
||||
"Följ en loggfil i realtid",
|
||||
"Visa kommandohistorik",
|
||||
"Rensa terminalskärmen"
|
||||
]
|
||||
},
|
||||
"english": [
|
||||
"List all Proxmox nodes",
|
||||
"List VMs on a node",
|
||||
"List LXC containers on a node",
|
||||
"List storage on a node",
|
||||
"List network interfaces",
|
||||
"List all QEMU/KVM virtual machines",
|
||||
"Start a virtual machine",
|
||||
"Stop a virtual machine",
|
||||
"Shut down a virtual machine safely",
|
||||
"Show VM status",
|
||||
"Show VM configuration",
|
||||
"Create a VM Snapshot",
|
||||
"List all LXC containers",
|
||||
"Start an LXC container",
|
||||
"Stop an LXC container",
|
||||
"Open a shell in an LXC container",
|
||||
"Show container configuration",
|
||||
"Show storage status",
|
||||
"List storage contents",
|
||||
"Test Proxmox system performance",
|
||||
"Show the Proxmox VE version",
|
||||
"Show pve-cluster service status",
|
||||
"Show cluster status",
|
||||
"List cluster nodes",
|
||||
"Show ZFS pool status",
|
||||
"List all ZFS pools",
|
||||
"List ZFS filesystems and volumes",
|
||||
"List all files with details",
|
||||
"Change directory",
|
||||
"Create a directory",
|
||||
"Remove a directory recursively",
|
||||
"Copy files",
|
||||
"Move or rename files",
|
||||
"Display file contents",
|
||||
"Search for a pattern in a file",
|
||||
"Find files by name",
|
||||
"Change file permissions",
|
||||
"Change the file owner",
|
||||
"Extract a tar.gz archive",
|
||||
"Create a tar.gz archive",
|
||||
"Show disk usage",
|
||||
"Show directory sizes",
|
||||
"Show memory usage",
|
||||
"Show running processes",
|
||||
"Find a running process",
|
||||
"Force-kill a process",
|
||||
"Check service status",
|
||||
"Start a service",
|
||||
"Stop a service",
|
||||
"Restart a service",
|
||||
"Update Debian/Ubuntu packages",
|
||||
"Install a Debian/Ubuntu package",
|
||||
"Remove a package",
|
||||
"List running containers",
|
||||
"List Docker images",
|
||||
"Open a shell in a container",
|
||||
"Show IP addresses",
|
||||
"Test network connectivity",
|
||||
"Get HTTP headers",
|
||||
"Download a file",
|
||||
"Connect through SSH",
|
||||
"Copy a file through SSH",
|
||||
"Follow a log file in real time",
|
||||
"Show command history",
|
||||
"Clear the terminal screen"
|
||||
],
|
||||
"commands": [
|
||||
"pvesh get /nodes",
|
||||
"pvesh get /nodes/{node}/qemu",
|
||||
"pvesh get /nodes/{node}/lxc",
|
||||
"pvesh get /nodes/{node}/storage",
|
||||
"pvesh get /nodes/{node}/network",
|
||||
"qm list",
|
||||
"qm start <vmid>",
|
||||
"qm stop <vmid>",
|
||||
"qm shutdown <vmid>",
|
||||
"qm status <vmid>",
|
||||
"qm config <vmid>",
|
||||
"qm snapshot <vmid> <snapname>",
|
||||
"pct list",
|
||||
"pct start <vmid>",
|
||||
"pct stop <vmid>",
|
||||
"pct enter <vmid>",
|
||||
"pct config <vmid>",
|
||||
"pvesm status",
|
||||
"pvesm list <storage>",
|
||||
"pveperf",
|
||||
"pveversion",
|
||||
"systemctl status pve-cluster",
|
||||
"pvecm status",
|
||||
"pvecm nodes",
|
||||
"zpool status",
|
||||
"zpool list",
|
||||
"zfs list",
|
||||
"ls -la",
|
||||
"cd /path/to/dir",
|
||||
"mkdir dirname",
|
||||
"rm -rf dirname",
|
||||
"cp source dest",
|
||||
"mv source dest",
|
||||
"cat filename",
|
||||
"grep 'pattern' file",
|
||||
"find . -name 'file'",
|
||||
"chmod 755 file",
|
||||
"chown user:group file",
|
||||
"tar -xzf file.tar.gz",
|
||||
"tar -czf archive.tar.gz dir/",
|
||||
"df -h",
|
||||
"du -sh *",
|
||||
"free -h",
|
||||
"top",
|
||||
"ps aux | grep process",
|
||||
"kill -9 PID",
|
||||
"systemctl status service",
|
||||
"systemctl start service",
|
||||
"systemctl stop service",
|
||||
"systemctl restart service",
|
||||
"apt update && apt upgrade",
|
||||
"apt install package",
|
||||
"apt remove package",
|
||||
"docker ps",
|
||||
"docker images",
|
||||
"docker exec -it container bash",
|
||||
"ip addr show",
|
||||
"ping host",
|
||||
"curl -I url",
|
||||
"wget url",
|
||||
"ssh user@host",
|
||||
"scp file user@host:/path",
|
||||
"tail -f /var/log/syslog",
|
||||
"history",
|
||||
"clear"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Execute the actual Security handlers against inert UI state and transport.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
|
||||
const catalogs = Object.fromEntries(['en','de','es','fr','it','pt','sk','sv'].map(lang => [lang, require(path.join(root, `AppImage/messages/${lang}/common.json`)).securityPage]));
|
||||
const begin = source.indexOf(' const handleUninstallLynis = async () => {');
|
||||
const end = source.indexOf('\n const loadFail2banDetails =', begin);
|
||||
assert(begin !== -1 && end > begin);
|
||||
const eraseTypes = text => text.replace(/fetchApi<\{[^}]+\}>/g, 'fetchApi')
|
||||
.replace(/ as \{ partial\?: boolean \} \| undefined/g, '');
|
||||
const uninstall = eraseTypes(source.slice(begin, end));
|
||||
const anchor = source.indexOf('if (confirm(st("confirm.deleteAuditReport")))');
|
||||
assert(anchor !== -1);
|
||||
const clickBegin = source.lastIndexOf('onClick={(e) => {', anchor);
|
||||
const clickEnd = source.indexOf('\n className=', anchor);
|
||||
assert(clickBegin !== -1 && clickEnd > anchor);
|
||||
const click = eraseTypes(source.slice(clickBegin + 'onClick={'.length, clickEnd).trim().replace(/}\s*$/, ''));
|
||||
async function exercise(lang, action, reply, priorSuccess = '') {
|
||||
const state = {report: {id: 'inert'}, expanded: true, success: priorSuccess, error: '', loads: 0, calls: []};
|
||||
const st = (key) => key.split('.').reduce((v, k) => v?.[k], catalogs[lang]);
|
||||
const ctx = {
|
||||
st, Error, confirm: () => true,
|
||||
setLynisReport: v => {state.report = v}, setLynisShowReport: v => {state.expanded = v},
|
||||
setSuccess: v => {state.success = v}, setError: v => {state.error = v},
|
||||
setUninstallingLynis: () => {}, setShowLynisUninstallConfirm: () => {},
|
||||
loadSecurityTools: () => {state.loads++},
|
||||
fetchApi: (endpoint, options) => {
|
||||
assert.equal(endpoint, action === 'delete' ? '/api/security/lynis/report' : '/api/security/lynis/uninstall');
|
||||
assert.equal(options.method, action === 'delete' ? 'DELETE' : 'POST');
|
||||
state.calls.push(options.method);
|
||||
return reply instanceof Error ? Promise.reject(reply) : Promise.resolve(reply);
|
||||
},
|
||||
};
|
||||
const fn = vm.runInNewContext(action === 'delete' ? `(${click})` : `${uninstall}; handleUninstallLynis`, ctx);
|
||||
await fn({stopPropagation() {}});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return state;
|
||||
}
|
||||
(async () => {
|
||||
for (const lang of Object.keys(catalogs)) {
|
||||
for (const action of ['delete', 'uninstall']) {
|
||||
const success = await exercise(lang, action, {success:true, message:'inert'});
|
||||
assert.equal(success.report, null, `${lang} ${action} clean report`);
|
||||
assert(success.success, `${lang} ${action} clean success`);
|
||||
assert.equal(success.error, '', `${lang} ${action} clean no error`);
|
||||
assert.equal(success.loads, 1);
|
||||
for (const [kind, reply] of [
|
||||
['no_files', {success:false,outcome:'no_files',message:'No report files found to delete'}],
|
||||
['malformed_success', {success:'false',message:'inert malformed response'}],
|
||||
['first_failure', {success:false,partial:false,message:'inert denied'}],
|
||||
['partial_failure', {success:false,partial:true,message:'inert denied'}],
|
||||
['http_failure', Object.assign(new Error('inert denied'), {body:{success:false,partial:true,message:'inert denied'}})],
|
||||
['nonjson_failure', new Error('Invalid JSON response')],
|
||||
['network_failure', new Error('network failure')],
|
||||
]) {
|
||||
const state = await exercise(lang, action, reply);
|
||||
assert.notEqual(state.report, null, `${lang} ${action} ${kind}: report retained`);
|
||||
assert.equal(state.success, '', `${lang} ${action} ${kind}: no success`);
|
||||
assert.equal(state.loads, 0, `${lang} ${action} ${kind}: no success refresh`);
|
||||
assert(state.error, `${lang} ${action} ${kind}: error visible`);
|
||||
const expectedKey = kind === 'no_files' ? (action === 'delete' ? 'deleteReportNoFiles' : 'lynisUninstallNoFiles')
|
||||
: kind === 'partial_failure' || kind === 'http_failure' ? 'lynisRemovalPartial'
|
||||
: 'lynisRemovalUnconfirmed';
|
||||
assert.equal(state.error, catalogs[lang].errors[expectedKey], `${lang} ${action} ${kind}: native whole-message feedback`);
|
||||
if (action === 'delete' && kind === 'no_files') {
|
||||
const stale = await exercise(lang, action, reply, 'Earlier unrelated success');
|
||||
assert.equal(stale.success, '', `${lang} delete clears stale success before failure`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('actual Lynis handlers: eight locales, clean/no-files/pre-first/partial/HTTP/non-JSON/network state');
|
||||
})().catch(err => { console.error(err); process.exitCode = 1 });
|
||||
@@ -0,0 +1,57 @@
|
||||
// Execute the actual printable-report generator, without mounting the app.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const root = path.resolve(__dirname, '../../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
|
||||
const begin = source.indexOf(' const generatePrintableReport = (report: LynisReport) => {');
|
||||
const end = source.indexOf('\n const loadSslStatus =', begin);
|
||||
assert(begin > 0 && end > begin, 'print consumer not found');
|
||||
const body = source.slice(begin, end)
|
||||
.replace('(report: LynisReport)', '(report)')
|
||||
.replace('(raw: unknown): string', '(raw)');
|
||||
const lang = process.env.LYNIS_FIXTURE_LANG || 'en';
|
||||
assert(['en','de','es','fr','it','pt','sk','sv'].includes(lang));
|
||||
const catalog = require(path.join(root, `AppImage/messages/${lang}/common.json`));
|
||||
const translate = (key, params = {}) => {
|
||||
const value = key.split('.').reduce((v, k) => v?.[k], catalog.securityPage);
|
||||
assert.equal(typeof value, 'string', `missing ${lang} key ${key}`);
|
||||
return value.replace(/\{(\w+)\}/g, (_, k) => String(params[k]));
|
||||
};
|
||||
const context = {
|
||||
st: translate,
|
||||
window: { location: { origin: 'https://offline.invalid' } },
|
||||
document: { documentElement: { lang } },
|
||||
getLynisScoreState: () => ({rawScore: 80, displayScore: 80, reportComplete: true, hasAdjustment: false}),
|
||||
getActionableCount: (total, expected) => Math.max(0, total - expected),
|
||||
lynisCountText: (key, count) => translate(`lynis.counts.${key}.${count === 1 ? 'one' : 'many'}`, { count }),
|
||||
};
|
||||
const render = vm.runInNewContext(`${body}\ngeneratePrintableReport`, context);
|
||||
const report = {
|
||||
hostname: 'inert', os_name: 'Linux', os_version: '', os_fullname: 'Linux',
|
||||
kernel_version: 'test', lynis_version: 'test', datetime_start: '2026-01-01',
|
||||
hardening_index: 80, tests_performed: 1,
|
||||
warnings: [
|
||||
{test_id: 'EXPECTED-W', description: 'expected warning', solution: '', proxmox_expected: true},
|
||||
{test_id: 'ACTION-W', description: 'actionable warning', solution: '', proxmox_expected: false},
|
||||
],
|
||||
suggestions: [
|
||||
{test_id: 'EXPECTED-S', description: 'expected suggestion', solution: '', proxmox_expected: true},
|
||||
{test_id: 'ACTION-S', description: 'actionable suggestion', solution: '', proxmox_expected: false},
|
||||
],
|
||||
proxmox_expected_warnings: 1, proxmox_expected_suggestions: 1, sections: [],
|
||||
};
|
||||
const html = render(report);
|
||||
if (process.env.LYNIS_FIXTURE_HTML) fs.writeFileSync(process.env.LYNIS_FIXTURE_HTML, html);
|
||||
for (const id of ['EXPECTED-W', 'ACTION-W', 'EXPECTED-S', 'ACTION-S']) {
|
||||
assert(html.includes(id), `missing actual finding ${id}`);
|
||||
}
|
||||
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-W[\s\S]*?f-tag-pve/);
|
||||
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-S[\s\S]*?f-tag-pve/);
|
||||
for (const key of ['lynis.report.warningsDescription', 'lynis.report.suggestionsDescription']) {
|
||||
const description = translate(key);
|
||||
assert(!/hidden|nascosti|ocult|caché|skryt|dold|versteckt/i.test(description));
|
||||
assert(html.includes(description), `missing rendered description ${key}`);
|
||||
}
|
||||
console.log(`print HTML (${lang}): expected/actionable findings shown and badged`);
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
|
||||
import ast
|
||||
import datetime
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import typing
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from notification_fixture import templates, SCRIPTS, EmailChannel, extract
|
||||
|
||||
|
||||
def visible(markup):
|
||||
class Text(HTMLParser):
|
||||
def __init__(self): super().__init__(); self.parts = []; self.tags = []
|
||||
def handle_data(self, data): self.parts.append(data)
|
||||
def handle_starttag(self, tag, attrs): self.tags.append(tag)
|
||||
parser = Text(); parser.feed(markup)
|
||||
return '\n'.join(p.strip() for p in parser.parts if p.strip()), parser.tags
|
||||
|
||||
|
||||
def restore_event(warnings):
|
||||
events = []
|
||||
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
|
||||
'hostname':'node-a', 'guests':'3', 'stubs':'1', 'stale_nodes':'0',
|
||||
'components':'2', 'duration':'2m', 'warnings':warnings}),
|
||||
'notification_manager':types.SimpleNamespace(emit_event=lambda **kw:events.append(kw)),
|
||||
'jsonify':lambda value:value}
|
||||
handler = extract(SCRIPTS/'flask_notification_routes.py', 'internal_restore_event', None, ns)
|
||||
response, status = handler()
|
||||
assert status == 200 and len(events) == 1
|
||||
return events[0]
|
||||
|
||||
|
||||
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
|
||||
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
|
||||
captured = []
|
||||
delivery_errors = []
|
||||
def record_history(event_type, channel, title, body, severity, success, error, source):
|
||||
if not success:
|
||||
delivery_errors.append(f'{channel}: {error}')
|
||||
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
|
||||
def sink(title, body, severity, data):
|
||||
markup = channel._format_html(title, body, severity, data)
|
||||
text, tags = visible(markup)
|
||||
captured.append(dict(title=title, body=body, severity=severity, data=dict(data), html=markup, text=text, tags=tags))
|
||||
return {'success':True}
|
||||
manager = types.SimpleNamespace(_config={'email.rich_format':'true'}, _lock=threading.RLock(),
|
||||
_channels={'email':types.SimpleNamespace(send=sink)},
|
||||
_group_limiter=types.SimpleNamespace(allow=lambda group:True),
|
||||
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
|
||||
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
|
||||
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
|
||||
_record_history=record_history, _stats={'total_sent':0,'total_errors':0},
|
||||
is_event_enabled=lambda event:True)
|
||||
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
|
||||
resolve_notification_hostname=lambda host, config:host or 'node-a',
|
||||
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
|
||||
format_backup_diagnostics_with_ai=templates.format_backup_diagnostics_with_ai,
|
||||
format_with_ai_full=templates.format_with_ai_full,
|
||||
# AI is disabled in this fixture. Execute the actual formatting helpers
|
||||
# synchronously, without a worker pool, providers or operational imports.
|
||||
_format_with_ai_bounded=lambda function,*args,**kwargs:function(*args,**kwargs),
|
||||
enrich_context_for_ai=lambda **kwargs:'')
|
||||
manager_path = SCRIPTS/'notification_manager.py'
|
||||
for node in ast.parse(manager_path.read_text()).body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in ('_AI_BYPASS_EVENTS', '_AI_NO_SUGGESTION_EVENTS'):
|
||||
ns[target.id] = frozenset(ast.literal_eval(node.value.args[0]))
|
||||
for name in ('_should_bypass_ai', '_format_backup_with_ai_bounded'):
|
||||
extract(manager_path, name, None, ns)
|
||||
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
|
||||
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
|
||||
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
|
||||
db = Path(scratch)/'pending.sqlite'
|
||||
rows = []
|
||||
if quiet:
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute('CREATE TABLE quiet_pending (id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, event_group TEXT, severity TEXT, ts INTEGER, title TEXT, body TEXT)')
|
||||
conn.commit(); conn.close()
|
||||
qns = dict(vars(typing), sqlite3=sqlite3, DB_PATH=db, time=time, datetime=datetime.datetime,
|
||||
_resolve_display_hostname=lambda config:'node-a', runtime_message=templates.runtime_message,
|
||||
EVENT_EMOJI=templates.EVENT_EMOJI, CATEGORY_EMOJI=templates.CATEGORY_EMOJI)
|
||||
for name in ('_buffer_quiet_event', '_flush_quiet_for_channel', '_compose_digest_body'):
|
||||
setattr(manager,name,types.MethodType(extract(SCRIPTS/'notification_manager.py',name,'NotificationManager',qns),manager))
|
||||
for earlier in quiet_before:
|
||||
manager._dispatch_event(types.SimpleNamespace(**earlier,source='inert',entity_type='node',entity_id='',event_id='inert',fingerprint='inert'))
|
||||
if manual:
|
||||
assert not quiet
|
||||
result = manager.send_notification(event_type, severity, '', '', dict(data))
|
||||
assert result['success'], result.get('errors')
|
||||
else:
|
||||
event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert')
|
||||
manager._dispatch_event(event)
|
||||
assert not delivery_errors, '; '.join(delivery_errors)
|
||||
if quiet:
|
||||
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
|
||||
assert len(rows)==1+len(quiet_before) and not captured
|
||||
manager._flush_quiet_for_channel('email', manager._channels['email'])
|
||||
conn=sqlite3.connect(db); remaining=conn.execute('SELECT count(*) FROM quiet_pending').fetchone()[0]; conn.close()
|
||||
assert remaining==0
|
||||
assert len(captured)==1
|
||||
if quiet: captured[0]['buffered']=rows
|
||||
return captured[0]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Assertion-free inert actual consumers; no operational host imports."""
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / 'AppImage/scripts'
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import notification_templates as templates
|
||||
# Display-name resolution is an infrastructure boundary, never load manager.
|
||||
templates._get_hostname = lambda: 'node-a'
|
||||
from notification_channels import EmailChannel
|
||||
LANGUAGES = ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
def extract(path, name, owner, ns):
|
||||
tree = ast.parse(path.read_text())
|
||||
nodes = tree.body if owner is None else next(n.body for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
|
||||
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
|
||||
return ns[name]
|
||||
|
||||
def receive(message, severity='info', title='Backup', kind='vzdump'):
|
||||
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
|
||||
class Event:
|
||||
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
|
||||
ns['NotificationEvent'] = Event
|
||||
methods = {name: extract(SCRIPTS / 'notification_events.py', name, 'ProxmoxHookWatcher', ns)
|
||||
for name in ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')}
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, event): self.items.append(event)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = methods['_classify_pve']
|
||||
_map_severity = staticmethod(methods['_map_severity'])
|
||||
_backup_outcome = staticmethod(methods['_backup_outcome'])
|
||||
process_webhook = methods['process_webhook']
|
||||
def __init__(self): self._queue = Queue()
|
||||
target = Receiver()
|
||||
result = target.process_webhook({'fields': {'type': kind}, 'severity': severity, 'title': title, 'message': message})
|
||||
assert result['accepted'] and len(target._queue.items) == 1
|
||||
return target._queue.items[0]
|
||||
|
||||
def email(event_type, data, severity='INFO', language='en'):
|
||||
result = templates.render_template(event_type, data, language)
|
||||
channel = object.__new__(EmailChannel)
|
||||
channel.subject_prefix = '[ProxMenux]'
|
||||
context = {**data, 'severity': severity, '_event_type': event_type, '_group': result['group'], '_notification_language': language}
|
||||
return result, channel._format_html(result['title'], result['body'], severity, context)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Source layout regressions for the Helper-Scripts/core repository split."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "generate_app_tracking_catalog.py"
|
||||
SPEC = importlib.util.spec_from_file_location("tracking_sources_under_test", SCRIPT)
|
||||
generator = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = generator
|
||||
SPEC.loader.exec_module(generator)
|
||||
|
||||
NORMALIZE = ''' app_lc="$(echo "${app,,}" | tr -d ' ')"'''
|
||||
READ_BODY = NORMALIZE + '''
|
||||
current_file="$HOME/.${app_lc}"
|
||||
current="$(<"$current_file")"
|
||||
'''
|
||||
WRITE_BODY = NORMALIZE + '''
|
||||
version_file="$HOME/.${app_lc}"
|
||||
echo "$version" >"$version_file"
|
||||
'''
|
||||
|
||||
|
||||
def function(name, body):
|
||||
return f"{name}() {{\n{body}\n}}\n"
|
||||
|
||||
|
||||
LEGACY = (
|
||||
function("check_for_gh_release", READ_BODY.replace(' current_file=', ' local current_file='))
|
||||
+ function("fetch_and_deploy_gh_release", WRITE_BODY.replace(' version_file=', ' local version_file='))
|
||||
)
|
||||
SPLIT = (
|
||||
function("check_for_gh_release", ' check_for_release github "$1" "$2" "${3:-}"')
|
||||
+ function("fetch_and_deploy_gh_release", ' fetch_and_deploy_release github "$1" "$2" "${3:-tarball}"')
|
||||
+ function("check_for_release", READ_BODY)
|
||||
+ function("fetch_and_deploy_release", WRITE_BODY)
|
||||
)
|
||||
|
||||
|
||||
class TrackingSourcesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.apps = self.root / "apps"
|
||||
self.core = self.root / "core"
|
||||
self.write(self.apps / "ct/example.sh", '''APP="Example"
|
||||
check_for_gh_release "Example" "example/example"
|
||||
''')
|
||||
self.write(self.apps / "install/example-install.sh", '''
|
||||
fetch_and_deploy_gh_release "Example" "example/example" "tarball" "latest"
|
||||
''')
|
||||
|
||||
def write(self, path, content):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def run_main(self, *extra):
|
||||
output = self.root / "output.json"
|
||||
args = ["--source-dir", str(self.apps), "--output", str(output),
|
||||
"--include-helper-markers",
|
||||
"--audit-output", str(self.root / "audit.json"),
|
||||
"--v2-output", str(self.root / "v2.json"), *extra]
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
result = generator.main(args)
|
||||
return result, output
|
||||
|
||||
def test_legacy_monolithic_contract_is_supported(self):
|
||||
self.write(self.apps / "misc/tools.func", LEGACY)
|
||||
result, output = self.run_main()
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(json.loads(output.read_text())["example"]["file_path"], "/root/.example")
|
||||
|
||||
def test_split_contract_builds_catalog_and_records_actual_evidence(self):
|
||||
self.write(self.core / "lib/forge.func", SPLIT)
|
||||
result, output = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(json.loads(output.read_text())["example"]["repo"], "example/example")
|
||||
audit = json.loads((self.root / "audit.json").read_text())
|
||||
self.assertEqual(audit["provenance"]["core"]["source_dir"], str(self.core.resolve()))
|
||||
v2 = (self.root / "v2.json").read_text()
|
||||
self.assertIn("core/lib/forge.func", v2)
|
||||
self.assertNotIn("misc/tools.func", v2)
|
||||
|
||||
def test_unsplit_core_tools_library_is_supported(self):
|
||||
path = self.write(self.core / "lib/tools.func", LEGACY)
|
||||
self.assertEqual(generator.shared_helper_path(self.apps, self.core), path)
|
||||
generator.verify_shared_helper(path)
|
||||
|
||||
def test_changed_contract_never_overwrites_existing_outputs(self):
|
||||
for old, new in [('echo "$version" >"$version_file"', 'echo "$version" >/tmp/version'),
|
||||
('current_file="$HOME/.${app_lc}"', 'current_file="/tmp/version"'),
|
||||
('tr -d \' \'', 'tr -d \'-\'')]:
|
||||
with self.subTest(change=old):
|
||||
self.write(self.core / "lib/forge.func", SPLIT.replace(old, new))
|
||||
output = self.write(self.root / "output.json", '{"keep": true}\n')
|
||||
result, _ = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(output.read_text(), '{"keep": true}\n')
|
||||
|
||||
def test_matching_text_in_unrelated_functions_is_not_evidence(self):
|
||||
path = self.write(self.core / "lib/forge.func", SPLIT.replace(
|
||||
function("check_for_release", READ_BODY), function("unrelated", READ_BODY)))
|
||||
with self.assertRaises(generator.CatalogError):
|
||||
generator.verify_shared_helper(path)
|
||||
|
||||
def test_split_local_tree_requires_explicit_local_core_without_network(self):
|
||||
with patch.object(generator.GitHubClient, "request", side_effect=AssertionError("Network")):
|
||||
result, output = self.run_main()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_invalid_local_core_fails_without_writing_a_catalog(self):
|
||||
result, output = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 1)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_snapshot_cache_accepts_core_without_ct_directory(self):
|
||||
sha = "a" * 40
|
||||
source = self.root / "cache/community-scripts--core" / sha
|
||||
self.write(source / "lib/forge.func", SPLIT)
|
||||
self.write(source / ".snapshot-complete", "archive-hash\n")
|
||||
client = Mock()
|
||||
client.json.return_value = {"sha": sha}
|
||||
path, commit, archive = generator.obtain_snapshot(
|
||||
client, "community-scripts/core", "main", self.root / "cache",
|
||||
required_paths=("lib/forge.func",))
|
||||
self.assertEqual((path, commit, archive), (source, sha, "archive-hash"))
|
||||
client.request.assert_not_called()
|
||||
|
||||
def test_remote_split_source_pins_both_repositories(self):
|
||||
self.write(self.core / "lib/forge.func", SPLIT)
|
||||
output = self.root / "remote.json"
|
||||
with patch.object(generator, "obtain_snapshot", side_effect=[
|
||||
(self.apps, "a" * 40, "apps-archive"),
|
||||
(self.core, "b" * 40, "core-archive"),
|
||||
]) as snapshots, contextlib.redirect_stdout(io.StringIO()):
|
||||
result = generator.main([
|
||||
"--output", str(output), "--include-helper-markers",
|
||||
"--audit-output", str(self.root / "remote-audit.json"),
|
||||
"--v2-output", str(self.root / "remote-v2.json"),
|
||||
])
|
||||
self.assertEqual(result, 0)
|
||||
provenance = json.loads((self.root / "remote-audit.json").read_text())["provenance"]
|
||||
self.assertEqual(provenance["commit_sha"], "a" * 40)
|
||||
self.assertEqual(provenance["core"]["commit_sha"], "b" * 40)
|
||||
self.assertEqual(provenance["core"]["archive_sha256"], "core-archive")
|
||||
self.assertEqual(snapshots.call_args_list[1].args[1], "community-scripts/core")
|
||||
self.assertEqual(snapshots.call_args_list[1].kwargs["required_paths"], ("lib/forge.func",))
|
||||
|
||||
def test_invalid_download_is_not_marked_complete(self):
|
||||
client = Mock()
|
||||
client.json.return_value = {"sha": "b" * 40}
|
||||
client.request.return_value = b"invalid-layout"
|
||||
with patch.object(generator, "safe_extract_tar"):
|
||||
with self.assertRaises(generator.CatalogError):
|
||||
generator.obtain_snapshot(client, "community-scripts/core", "main", self.root / "cache",
|
||||
required_paths=("lib/forge.func",))
|
||||
self.assertEqual(list((self.root / "cache").rglob(".snapshot-complete")), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""A host backup and a finished restore tell the Monitor on its own port,
|
||||
over HTTPS once the Monitor has a certificate."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LIBRARY = ROOT / 'scripts/backup_restore/lib_host_backup_common.sh'
|
||||
POSTBOOT = ROOT / 'scripts/backup_restore/apply_cluster_postboot.sh'
|
||||
SETTING = '/etc/proxmenux/ssl_config.json'
|
||||
|
||||
|
||||
def function(text, name):
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}\n', start) + 3]
|
||||
|
||||
|
||||
class BackupNotifyHttps(TestCase):
|
||||
def address(self, setting):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'ssl_config.json'
|
||||
if setting is not None:
|
||||
path.write_text(setting)
|
||||
body = function(LIBRARY.read_text(), 'hb_notify_lifecycle').replace(SETTING, str(path))
|
||||
# The function silences curl, so the stub writes the address it was given.
|
||||
called = Path(folder) / 'called'
|
||||
script = (f'curl() {{ printf "%s" "${{@: -1}}" > {called}; }}\n' + body
|
||||
+ 'HB_NOTIFY_JOB_ID=job1 hb_notify_lifecycle complete\n')
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return called.read_text()
|
||||
|
||||
def test_plain_http_while_the_monitor_has_no_certificate(self):
|
||||
expected = 'http://127.0.0.1:8008/api/notifications/webhook'
|
||||
self.assertEqual(self.address(None), expected)
|
||||
self.assertEqual(self.address('{"enabled": false}'), expected)
|
||||
|
||||
def test_https_once_the_monitor_has_a_certificate(self):
|
||||
self.assertEqual(self.address('{"enabled": true, "source": "proxmox"}'),
|
||||
'https://127.0.0.1:8008/api/notifications/webhook')
|
||||
|
||||
def test_the_finished_restore_follows_the_same_setting(self):
|
||||
source = POSTBOOT.read_text()
|
||||
self.assertIn('"${NOTIFY_SCHEME}://127.0.0.1:8008/api/internal/restore-event"', source)
|
||||
self.assertNotIn('"http://127.0.0.1:8008/api/internal/restore-event"', source)
|
||||
check = source[source.index('NOTIFY_SCHEME="http"'):source.index('NOTIFY_HTTP=$(curl')]
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'ssl_config.json'
|
||||
for setting, expected in (('{"enabled": true}', 'https'), ('{"enabled":false}', 'http'), (None, 'http')):
|
||||
path.unlink(missing_ok=True)
|
||||
if setting is not None:
|
||||
path.write_text(setting)
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c',
|
||||
check.replace(SETTING, str(path)) + '\necho "$NOTIFY_SCHEME"'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.stdout.strip(), expected, setting)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""A schedule systemd rejects leaves a timer that never fires and reports no
|
||||
error, so the wizard asks for it again instead of saving it."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCHEDULER = Path(__file__).resolve().parents[3] / 'scripts/backup_restore/backup_scheduler.sh'
|
||||
ANALYZE = '#!/bin/sh\ncase "$2" in "daily 02:00") exit 1;; esac\nexit 0\n'
|
||||
|
||||
|
||||
def function(name):
|
||||
text = SCHEDULER.read_text()
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}', start) + 2] + '\n'
|
||||
|
||||
|
||||
class ScheduleValidation(unittest.TestCase):
|
||||
def valid(self, expression, **environment):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
analyze = Path(directory) / 'systemd-analyze'
|
||||
analyze.write_text(ANALYZE)
|
||||
analyze.chmod(0o755)
|
||||
return subprocess.run(
|
||||
['/bin/bash', '-c', function('_calendar_is_valid') + '_calendar_is_valid "$1"', 'test', expression],
|
||||
env={'PATH': f'{directory}:/usr/bin:/bin', **environment},
|
||||
).returncode == 0
|
||||
|
||||
def test_systemd_decides_what_is_valid(self):
|
||||
self.assertTrue(self.valid('02:30'))
|
||||
self.assertFalse(self.valid('daily 02:00'))
|
||||
|
||||
def test_a_scheduler_without_systemctl_is_not_checked(self):
|
||||
self.assertTrue(self.valid('daily 02:00', PMX_BACKUP_NO_SYSTEMCTL='1'))
|
||||
|
||||
def test_the_wizard_asks_again_before_saving_the_job(self):
|
||||
text = SCHEDULER.read_text()
|
||||
check = text.index('while ! _calendar_is_valid "$on_calendar"; do')
|
||||
self.assertLess(check, text.index('"ON_CALENDAR=$on_calendar"'))
|
||||
self.assertGreater(check, text.index('on_calendar=$(dialog'))
|
||||
|
||||
|
||||
STUB_ANALYZE = '#!/bin/sh\ncase "$2" in daily\\ *|weekly\\ *|monthly\\ *) exit 1;; esac\nexit 0\n'
|
||||
JOBS = {
|
||||
'nas': 'ON_CALENDAR=daily\\ 02:00\nENABLED=1\n',
|
||||
'week': 'ON_CALENDAR=weekly\\ 03:15\nENABLED=1\n',
|
||||
'month': 'ON_CALENDAR=monthly\\ 04:00\nENABLED=0\n',
|
||||
'good': 'ON_CALENDAR=Mon..Fri\\ 03:00\nENABLED=1\n',
|
||||
'manual': 'ON_CALENDAR=daily\\ 05:00\nMANUAL_RUN=1\n',
|
||||
'attached': 'ON_CALENDAR=daily\\ 06:00\nPVE_STORAGE=local\n',
|
||||
'custom': 'ON_CALENDAR=every\\ night\nENABLED=1\n',
|
||||
}
|
||||
|
||||
|
||||
class ScheduleRepair(unittest.TestCase):
|
||||
def run_repair(self):
|
||||
names = ('_calendar_is_valid', '_scheduler_systemctl', '_job_env_get', '_job_is_attached',
|
||||
'_write_job_units', '_repaired_calendar', '_repair_job_schedules')
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
root = Path(self.folder.name)
|
||||
for name, body in (('systemd-analyze', STUB_ANALYZE),
|
||||
('systemctl', f'#!/bin/sh\necho "$@" >> {root}/systemctl.log\n')):
|
||||
(root / name).write_text(body)
|
||||
(root / name).chmod(0o755)
|
||||
jobs, units = root / 'jobs', root / 'units'
|
||||
jobs.mkdir()
|
||||
for job, body in JOBS.items():
|
||||
(jobs / f'{job}.env').write_text(f'JOB_ID={job}\n{body}')
|
||||
script = (f'JOBS_DIR={jobs}\nSYSTEMD_DIR={units}\nLOCAL_SCRIPTS={root}\nSCRIPT_DIR={root}\n'
|
||||
'_job_file() { echo "${JOBS_DIR}/$1.env"; }\n'
|
||||
'_service_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.service"; }\n'
|
||||
'_timer_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.timer"; }\n'
|
||||
+ ''.join(function(name) for name in names) + '_repair_job_schedules\n')
|
||||
subprocess.run(['/bin/bash', '-c', script], check=True,
|
||||
env={'PATH': f'{root}:/usr/bin:/bin'})
|
||||
return jobs, units, root
|
||||
|
||||
def test_a_time_after_daily_weekly_or_monthly_is_repaired(self):
|
||||
jobs, units, root = self.run_repair()
|
||||
self.assertIn('ON_CALENDAR=02:00\n', (jobs / 'nas.env').read_text())
|
||||
self.assertIn('OnCalendar=02:00\n', (units / 'proxmenux-backup-nas.timer').read_text())
|
||||
self.assertIn('OnCalendar=Mon 03:15\n', (units / 'proxmenux-backup-week.timer').read_text())
|
||||
self.assertIn('OnCalendar=*-*-01 04:00\n', (units / 'proxmenux-backup-month.timer').read_text())
|
||||
log = (root / 'systemctl.log').read_text()
|
||||
self.assertIn('enable --now proxmenux-backup-nas.timer', log)
|
||||
self.assertNotIn('proxmenux-backup-month.timer', log)
|
||||
|
||||
def test_the_rest_of_the_jobs_are_left_as_they_are(self):
|
||||
jobs, units, _ = self.run_repair()
|
||||
for job in ('good', 'manual', 'attached', 'custom'):
|
||||
self.assertEqual((jobs / f'{job}.env').read_text(), f'JOB_ID={job}\n{JOBS[job]}', job)
|
||||
self.assertFalse((units / f'proxmenux-backup-{job}.timer').exists(), job)
|
||||
|
||||
def test_both_installers_repair_on_update(self):
|
||||
root = SCHEDULER.parents[2]
|
||||
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
self.assertIn('backup_scheduler.sh" --repair-schedules', (root / installer).read_text())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Run only the Bashrc functions, with all root files in a private fixture."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
FLOWS = ("auto_post_install.sh", "customizable_post_install.sh")
|
||||
BEGIN = b"# BEGIN PMX_CORE_BASHRC\n"
|
||||
END = b"# END PMX_CORE_BASHRC\n"
|
||||
# The unmarked block shipped in v1.1.3 (fc974995), without its leading blank line.
|
||||
LEGACY = rb'''# ProxMenux customizations
|
||||
export HISTTIMEFORMAT="%d/%m/%y %T "
|
||||
export PS1="\[\e[31m\][\[\e[m\]\[\e[38;5;172m\]\u\[\e[m\]@\[\e[38;5;153m\]\h\[\e[m\] \[\e[38;5;214m\]\W\[\e[m\]\[\e[31m\]]\[\e[m\]\\$ "
|
||||
alias l='ls -CF'
|
||||
alias la='ls -A'
|
||||
alias ll='ls -alF'
|
||||
alias ls='ls --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias fgrep='fgrep --color=auto'
|
||||
alias egrep='egrep --color=auto'
|
||||
source /etc/profile.d/bash_completion.sh
|
||||
'''
|
||||
|
||||
|
||||
def extract(source, name):
|
||||
match = re.search(rf"(?ms)^{name}\(\) \{{\n.*?^\}}$", source)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing function: {name}")
|
||||
return match.group()
|
||||
|
||||
|
||||
class BashrcMigrationTests(unittest.TestCase):
|
||||
def run_flow(self, flow, original, times=1, injection="", style=""):
|
||||
with tempfile.TemporaryDirectory(prefix="bashrc-migration-") as temporary:
|
||||
work = Path(temporary)
|
||||
bashrc = work / ".bashrc"
|
||||
bashrc.write_bytes(original)
|
||||
bashrc.chmod(0o640)
|
||||
before = bashrc.stat()
|
||||
(work / "bin").mkdir()
|
||||
# The embedded Python must use the interpreter running this test.
|
||||
(work / "bin/python3").symlink_to(sys.executable)
|
||||
source = (ROOT / "scripts/post_install" / flow).read_text()
|
||||
functions = "\n".join(extract(source, name) for name in
|
||||
("_migrate_proxmenux_bashrc", "customize_bashrc"))
|
||||
# Relocate only the fixture copy; never source the administrative script.
|
||||
functions = functions.replace("/root/", str(work) + "/")
|
||||
prelude = f'source {shlex.quote(str(ROOT / "scripts/global/pmx_journal.sh"))}\n'
|
||||
prelude += r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_info() { :; }
|
||||
msg_info2() { :; }
|
||||
msg_warn() { :; }
|
||||
msg_error() { printf '%s\n' "$*" >&2; }
|
||||
msg_ok() { printf 'success\n' >> "$FIXTURE_ROOT/messages"; }
|
||||
register_tool() { printf '%s|%s|%s\n' "$@" >> "$FIXTURE_ROOT/registry"; }
|
||||
'''
|
||||
calls = "\n".join(
|
||||
f'customize_bashrc; rc=$?; printf "%s\\n" "$rc" >> "$FIXTURE_ROOT/status"; '
|
||||
f'cp "$FIXTURE_ROOT/.bashrc" "$FIXTURE_ROOT/pass-{i}"'
|
||||
for i in range(times))
|
||||
env = os.environ.copy()
|
||||
env.update(HOME=str(work), FIXTURE_ROOT=str(work), TMPDIR=str(work),
|
||||
PMX_JOURNAL_ROOT=str(work / "journal"), PMX_BASHRC_PATH_STYLE=style,
|
||||
PATH=str(work / "bin") + os.pathsep + env.get("PATH", ""))
|
||||
result = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c",
|
||||
prelude + functions + "\n" + injection + "\n" + calls],
|
||||
cwd=work, env=env, stdin=subprocess.DEVNULL, capture_output=True,
|
||||
text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
after = bashrc.stat()
|
||||
def read(name):
|
||||
path = work / name
|
||||
return path.read_bytes() if path.exists() else b""
|
||||
return {
|
||||
"passes": [read(f"pass-{i}") for i in range(times)],
|
||||
"status": read("status").splitlines(), "registry": read("registry"),
|
||||
"messages": read("messages"), "backup": read(".bashrc.bak"),
|
||||
"profile": read(".bash_profile"), "stderr": result.stderr,
|
||||
"detected_style": read("style"),
|
||||
"before": (stat.S_IMODE(before.st_mode), before.st_uid, before.st_gid),
|
||||
"after": (stat.S_IMODE(after.st_mode), after.st_uid, after.st_gid),
|
||||
"inode_before": before.st_ino, "inode_after": after.st_ino,
|
||||
"leftovers": [p.name for p in work.iterdir() if "proxmenux" in p.name],
|
||||
"journal": [json.loads(p.read_text()) for p in
|
||||
sorted((work / "journal/spool").glob("*.json"))],
|
||||
}
|
||||
|
||||
def test_inspect_detects_style_without_rewriting_the_original(self):
|
||||
for flow in FLOWS:
|
||||
for block in (LEGACY, LEGACY.replace(rb"\W", rb"\w")):
|
||||
with self.subTest(flow=flow, full=rb"\w" in block):
|
||||
injection = r'''customize_bashrc() {
|
||||
_migrate_proxmenux_bashrc "$FIXTURE_ROOT/.bashrc" inspect > "$FIXTURE_ROOT/style"
|
||||
}'''
|
||||
run = self.run_flow(flow, block, injection=injection)
|
||||
self.assertEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [block])
|
||||
self.assertEqual(run["detected_style"], b"full\n" if rb"\w" in block else b"short\n")
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["backup"], b"")
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["journal"], [])
|
||||
|
||||
def test_historical_blocks_migrate_once_with_prompt_backup_and_journal(self):
|
||||
for flow in FLOWS:
|
||||
for full in (False, True):
|
||||
legacy = LEGACY.replace(rb"\W", rb"\w") if full else LEGACY
|
||||
blocks = (legacy, b"${marker_begin}\n" + legacy + b"${marker_end}\n",
|
||||
BEGIN + legacy + END, legacy + BEGIN + legacy + END)
|
||||
for block in blocks:
|
||||
with self.subTest(flow=flow, full=full, block=block[:40]):
|
||||
original = b"# user prefix\n" + block + b"# user suffix\n"
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
first, second = run["passes"]
|
||||
self.assertEqual(first, second)
|
||||
self.assertTrue(first.startswith(b"# user prefix\n# user suffix\n"))
|
||||
self.assertEqual(first.count(BEGIN), 1)
|
||||
self.assertEqual(first.count(END), 1)
|
||||
self.assertEqual(first.count(b"alias ll="), 1)
|
||||
self.assertNotIn(b"${marker_", first)
|
||||
self.assertIn(rb"\w" if full else rb"\W", first)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(len(run["profile"].splitlines()), 1)
|
||||
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 2)
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
entries = [e for e in run["journal"] if e["target"].endswith("/.bashrc")]
|
||||
self.assertEqual(len(entries), 1)
|
||||
entry = entries[0]
|
||||
self.assertEqual(entry["operation"], "write_file")
|
||||
self.assertEqual(entry["exactness"], "exact")
|
||||
self.assertEqual(entry["capture"], "present")
|
||||
self.assertNotEqual(entry["before"], entry["after"])
|
||||
self.assertTrue(all("proxmenux." not in e["target"] for e in run["journal"]))
|
||||
syntax = subprocess.run(["bash", "-n"], input=first, capture_output=True)
|
||||
self.assertEqual(syntax.returncode, 0, syntax.stderr)
|
||||
|
||||
def test_incomplete_and_oversized_ranges_are_preserved_on_rerun(self):
|
||||
fragments = (LEGACY.rsplit(b"source", 1)[0], END,
|
||||
b"${marker_begin}\n# user\n", b"${marker_end}\n",
|
||||
BEGIN + b"# user\n" * 33 + END,
|
||||
b"${marker_begin}\n" + b"# user\n" * 33 + b"${marker_end}\n",
|
||||
BEGIN + b"# user\n${marker_begin}\n# inner\n${marker_end}\n# suffix\n")
|
||||
for flow in FLOWS:
|
||||
for fragment in fragments:
|
||||
with self.subTest(flow=flow, fragment=fragment[:40]):
|
||||
run = self.run_flow(flow, fragment, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
# Complete inner ranges can migrate, but their outer prefix cannot.
|
||||
preserved = BEGIN + b"# user\n# suffix\n" if b"# inner" in fragment else fragment
|
||||
self.assertTrue(run["passes"][0].startswith(preserved))
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
|
||||
def test_nested_ranges_with_outer_end_are_preserved_on_repeated_runs(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
for flow in FLOWS:
|
||||
for outer_begin, outer_end in markers:
|
||||
for inner_begin, inner_end in markers:
|
||||
with self.subTest(flow=flow, outer=outer_begin, inner=inner_begin):
|
||||
original = (outer_begin + b"# OUTER_USER_PREFIX\n"
|
||||
b"user_function() { :; }\n" + inner_begin
|
||||
+ b"# old inner block\n" + inner_end
|
||||
+ b"# OUTER_USER_SUFFIX\n" + outer_end)
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
for content in run["passes"]:
|
||||
self.assertTrue(content.startswith(original), content)
|
||||
self.assertEqual(content.count(BEGIN), original.count(BEGIN) + 1)
|
||||
self.assertEqual(content.count(END), original.count(END) + 1)
|
||||
self.assertEqual(content.count(b"# ProxMenux core customizations\n"), 1)
|
||||
self.assertEqual(run["passes"], [run["passes"][0]] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
|
||||
def test_crossed_marker_families_preserve_every_byte_on_repeated_runs(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
for flow in FLOWS:
|
||||
canonical = self.run_flow(flow, b"")["passes"][0]
|
||||
for outer, inner in (markers, markers[::-1]):
|
||||
for newline in (b"\n", b"\r\n"):
|
||||
with self.subTest(flow=flow, outer=outer[0], newline=newline):
|
||||
original = (outer[0] + b"user_prefix() { :; } # \xff\n"
|
||||
+ inner[0] + outer[1]
|
||||
+ b"user_suffix() { :; }\n" + inner[1])
|
||||
original = original.replace(b"\n", newline)
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
self.assertEqual(run["passes"], [original + canonical] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 3)
|
||||
|
||||
def test_marker_ambiguity_matrix_preserves_spans_without_capturing_separate_blocks(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
prefix = b"user_prefix() { :; }\n"
|
||||
suffix = b"user_suffix() { :; }\n"
|
||||
for flow in FLOWS:
|
||||
canonical = self.run_flow(flow, b"")["passes"][0]
|
||||
for (begin, end), (foreign_begin, foreign_end) in (markers, markers[::-1]):
|
||||
# Each malformed complete span is ambiguous, even if the foreign
|
||||
# token has no partner. Never expose its user bytes on a rerun.
|
||||
ambiguous = {
|
||||
"unmatched-inner-begin": begin + prefix + foreign_begin + suffix + end,
|
||||
"unmatched-foreign-end": begin + prefix + foreign_end + suffix + end,
|
||||
"inner-begin-extra-end": begin + prefix + foreign_begin + end + suffix + end,
|
||||
"nested-with-stray-end": (begin + prefix + foreign_begin + end
|
||||
+ foreign_end + suffix + end),
|
||||
"overlap-chain": (begin + prefix + foreign_begin + end + begin
|
||||
+ foreign_end + suffix + end),
|
||||
"ambiguous-legacy-signature": (begin + prefix + foreign_end
|
||||
+ LEGACY + suffix + end),
|
||||
"oversized-ambiguous-span": (begin + prefix + foreign_end
|
||||
+ b"# user\n" * 33 + suffix + end),
|
||||
}
|
||||
cases = [(name, fragment, fragment) for name, fragment in ambiguous.items()]
|
||||
# An unclosed old BEGIN is not an enclosing range. Its prefix
|
||||
# must survive, but independent complete blocks must still migrate.
|
||||
cases.extend([
|
||||
("orphan-before-same-family", begin + prefix + begin + b"old\n" + end + suffix,
|
||||
begin + prefix + suffix),
|
||||
("orphan-before-other-family", begin + prefix + foreign_begin + b"old\n"
|
||||
+ foreign_end + suffix, begin + prefix + suffix),
|
||||
("missing-outer-end", begin + prefix + foreign_begin + b"old\n"
|
||||
+ foreign_end + suffix + foreign_end, begin + prefix + suffix + foreign_end),
|
||||
("separated-valid-blocks", foreign_end + prefix + begin + b"old\n" + end
|
||||
+ suffix + foreign_begin + b"old\n" + foreign_end + foreign_begin,
|
||||
foreign_end + prefix + suffix + foreign_begin),
|
||||
("limit-32", begin + b"# old\n" * 31 + end + suffix, suffix),
|
||||
("over-limit-33", begin + b"# user\n" * 32 + end + suffix,
|
||||
begin + b"# user\n" * 32 + end + suffix),
|
||||
])
|
||||
for name, original, preserved in cases:
|
||||
with self.subTest(flow=flow, family=begin, case=name):
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
self.assertEqual(run["passes"], [preserved + canonical] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
|
||||
def test_path_override_and_empty_install_remain_idempotent(self):
|
||||
for flow in FLOWS:
|
||||
for style in ("short", "full"):
|
||||
with self.subTest(flow=flow, style=style):
|
||||
run = self.run_flow(flow, b"", times=2, style=style)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
self.assertIn(rb"\w" if style == "full" else rb"\W", run["passes"][0])
|
||||
|
||||
def test_preparation_and_commit_failures_leave_bashrc_untouched(self):
|
||||
failures = {
|
||||
"inspect": 'python3() { [[ "$3" == inspect ]] && return 71; command python3 "$@"; }',
|
||||
"migrate": 'python3() { [[ "$3" == migrate ]] && return 71; command python3 "$@"; }',
|
||||
"replace": '''python3() {
|
||||
command python3 -c 'import os, sys
|
||||
sys.argv = sys.argv[1:]
|
||||
def fail(*args):
|
||||
raise OSError("injected replace failure")
|
||||
os.replace = fail
|
||||
exec(sys.stdin.read())' "$@"
|
||||
}''',
|
||||
"backup": 'cp() { [[ "${@: -1}" == *.bak ]] && return 71; command cp "$@"; }',
|
||||
"mktemp": 'mktemp() { return 71; }',
|
||||
"copy": 'cp() { if [[ "$1" == -p ]]; then printf partial > "$3"; return 71; fi; command cp "$@"; }',
|
||||
"profile": 'pmx_append_file() { return 71; }',
|
||||
"commit": 'mv() { [[ "${@: -1}" == "$FIXTURE_ROOT/.bashrc" ]] && return 71; command mv "$@"; }',
|
||||
}
|
||||
original = b"# user\n" + LEGACY
|
||||
for flow in FLOWS:
|
||||
for failure, injection in failures.items():
|
||||
with self.subTest(flow=flow, failure=failure):
|
||||
run = self.run_flow(flow, original, injection=injection)
|
||||
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["messages"], b"")
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
self.assertFalse(any(e["target"].endswith("/.bashrc") for e in run["journal"]))
|
||||
with self.subTest(flow=flow, failure="invalid-style"):
|
||||
run = self.run_flow(flow, original, style="invalid")
|
||||
self.assertNotEqual(run["status"], [b"0"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
|
||||
def test_migration_preserves_unrelated_non_utf8_bytes_and_line_endings(self):
|
||||
prefix = b"# user bytes: \xff\r\n"
|
||||
suffix = b"user_function() { :; }\r\n"
|
||||
original = prefix + BEGIN + b"old block\n" + END + suffix
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
self.assertTrue(run["passes"][0].startswith(prefix + suffix))
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
|
||||
def test_failed_block_write_preserves_original_without_success(self):
|
||||
original = b"# user content\n" + BEGIN + b"old managed block\n" + END
|
||||
# Fail the real stdin writer after a partial canonical-block write.
|
||||
# The override is confined to this shell fixture, not production.
|
||||
injection = r'''
|
||||
cat() {
|
||||
if [[ $# == 0 ]]; then
|
||||
local first
|
||||
IFS= read -r first
|
||||
if [[ "$first" == '# BEGIN PMX_CORE_BASHRC' ]]; then
|
||||
printf '# partial block'
|
||||
return 71
|
||||
fi
|
||||
printf '%s\n' "$first"
|
||||
fi
|
||||
command cat "$@"
|
||||
}
|
||||
'''
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, injection=injection)
|
||||
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["messages"], b"")
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
|
||||
def test_orphan_begin_cannot_consume_user_content_on_rerun(self):
|
||||
original = BEGIN + b"user_function() { :; }\n# user suffix\n"
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
for content in run["passes"]:
|
||||
self.assertTrue(content.startswith(original), content)
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Offline regression tests for the Monitor catalog generator."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "build_i18n_messages.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_i18n_messages", SCRIPT)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class I18nMessagesTests(unittest.TestCase):
|
||||
def run_generator(self, source, target, *options, provider=None):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
en = root / "en" / "common.json"
|
||||
it = root / "it" / "common.json"
|
||||
en.parent.mkdir()
|
||||
it.parent.mkdir()
|
||||
en.write_text(json.dumps(source), encoding="utf-8")
|
||||
it.write_text(json.dumps(target), encoding="utf-8")
|
||||
before = it.read_bytes()
|
||||
argv = [str(SCRIPT), "--source", str(en), "--messages-dir", str(root),
|
||||
"--languages", "it", "--sleep", "0", *options]
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||
MODULE, "translate_one", side_effect=provider or (lambda text, *args: "IT " + text)
|
||||
) as translate, redirect_stdout(output), redirect_stderr(output):
|
||||
result = MODULE.main()
|
||||
return result, json.loads(it.read_text()), translate.call_args_list, output.getvalue(), before == it.read_bytes()
|
||||
|
||||
def test_generator_fills_array_leaves_preserving_existing_values(self):
|
||||
source = {"items": ["One", {"title": "Two", "children": ["Three"]}],
|
||||
"empty": [], "object": {}, "enabled": True, "count": 2, "none": None}
|
||||
target = {"items": ["Uno"], "extra": {"0": "Keep"}}
|
||||
result, actual, calls, _, _ = self.run_generator(source, target, "--save-every", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, {**source, "items": ["Uno", {"title": "IT Two", "children": ["IT Three"]}],
|
||||
"extra": {"0": "Keep"}})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Two", "Three"])
|
||||
|
||||
def test_wrong_types_defer_locale_without_calls_or_writes_even_on_refresh(self):
|
||||
source = {"new": "New", "terminal": {"commandDescriptions": ["One", "Two"]}}
|
||||
for malformed in ["['Uno', 'Due']", '["Uno", "Due"]', "ambiguous human text", {}, None, 7]:
|
||||
for options in [(), ("--refresh",)]:
|
||||
with self.subTest(malformed=malformed, options=options):
|
||||
target = {"terminal": {"commandDescriptions": malformed}, "human": "Keep"}
|
||||
result, actual, calls, output, unchanged = self.run_generator(source, target, *options)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(actual, target)
|
||||
self.assertTrue(unchanged)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertIn("/terminal/commandDescriptions", output)
|
||||
self.assertIn("manual", output)
|
||||
|
||||
def test_structure_only_catalog_is_written_without_translation(self):
|
||||
source = {"items": ["", [], {}, None, False, 0]}
|
||||
result, actual, calls, _, _ = self.run_generator(source, {})
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, source)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_limit_keeps_array_positions_as_null_fallbacks(self):
|
||||
result, actual, calls, _, _ = self.run_generator(
|
||||
{"items": ["One", "", "Three", "Four"]}, {}, "--limit", "1")
|
||||
self.assertEqual(actual, {"items": ["IT One", "", None, None]})
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_existing_nonempty_including_english_and_stale_text_is_unchanged(self):
|
||||
source = {"items": ["Changed English", "CPU", "Three"]}
|
||||
target = {"items": ["Human translation", "CPU", "Tre", "Extra"]}
|
||||
result, actual, calls, _, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(actual, target)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertTrue(unchanged)
|
||||
|
||||
def test_second_run_retries_null_placeholders_preserving_translations(self):
|
||||
source = {"items": ["One", "Two", "", "Three", "Four"]}
|
||||
target = {"items": ["Human"], "extra": "Keep"}
|
||||
result, first, calls, _, _ = self.run_generator(source, target, "--limit", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(first, {"items": ["Human", "IT Two", "", None, None], "extra": "Keep"})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Two"])
|
||||
result, second, calls, _, _ = self.run_generator(source, first, "--save-every", "1")
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(second, {"items": ["Human", "IT Two", "", "IT Three", "IT Four"], "extra": "Keep"})
|
||||
self.assertEqual([call.args[0] for call in calls], ["Three", "Four"])
|
||||
|
||||
def test_refresh_and_provider_failure_keep_existing_fallback_semantics(self):
|
||||
def provider(text, *args):
|
||||
if text == "Two":
|
||||
raise RuntimeError("offline fixture failure")
|
||||
return "IT " + text
|
||||
# --refresh alone never replaces an existing translation.
|
||||
result, actual, calls, output, _ = self.run_generator(
|
||||
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
|
||||
"--refresh", "--save-every", "1", provider=provider)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, {"items": ["Uno", "Due", "IT Three"]})
|
||||
self.assertEqual(len(calls), 1)
|
||||
# Named keys are retranslated; a provider failure keeps the value.
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as keys:
|
||||
keys.write("items.0\nitems.1\n")
|
||||
try:
|
||||
result, actual, calls, output, _ = self.run_generator(
|
||||
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
|
||||
"--refresh", "--refresh-keys", keys.name, "--save-every", "1", provider=provider)
|
||||
finally:
|
||||
Path(keys.name).unlink()
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(actual, {"items": ["IT One", "Due", "IT Three"]})
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertIn("1 translation failures", output)
|
||||
|
||||
def test_null_and_empty_string_are_filled_and_tokens_restored(self):
|
||||
source = {"items": ["Hello {vmid}", "Use Proxmox"]}
|
||||
result, actual, calls, _, _ = self.run_generator(source, {"items": [None, ""]})
|
||||
self.assertEqual(actual, {"items": ["IT Hello {vmid}", "IT Use Proxmox"]})
|
||||
self.assertNotIn("{vmid}", calls[0].args[0])
|
||||
self.assertNotIn("Proxmox", calls[1].args[0])
|
||||
|
||||
def test_root_pointer_is_empty_not_empty_key_pointer(self):
|
||||
for source, target, pointer in [({"a": "Text"}, [], 'JSON Pointer "" (root)'),
|
||||
({"": []}, {"": "Text"}, 'JSON Pointer "/"'),
|
||||
({"a~/": []}, {"a~/": "Text"}, 'JSON Pointer "/a~0~1"')]:
|
||||
with self.subTest(pointer=pointer):
|
||||
result, _, calls, output, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertTrue(unchanged)
|
||||
self.assertIn(pointer + ": expected", output)
|
||||
|
||||
def test_wrong_scalar_and_nested_types_are_rejected(self):
|
||||
for source, target in [({"a": "Text"}, {"a": 42}),
|
||||
({"a": [{"b": "Text"}]}, {"a": ["human"]}),
|
||||
({"a": False}, {"a": 0}),
|
||||
({"a": {}}, {"a": []})]:
|
||||
with self.subTest(source=source):
|
||||
result, actual, calls, _, unchanged = self.run_generator(source, target)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertTrue(unchanged)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_real_english_array_round_trip_and_generation(self):
|
||||
catalog = json.loads((SCRIPT.parents[2] / "AppImage/messages/en/common.json").read_text())
|
||||
descriptions = catalog["terminal"]["commandDescriptions"]
|
||||
self.assertIsInstance(descriptions, list)
|
||||
self.assertTrue(descriptions)
|
||||
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
|
||||
result, actual, calls, _, _ = self.run_generator({"terminal": {"commandDescriptions": descriptions}}, {})
|
||||
self.assertEqual(len(calls), sum(isinstance(text, str) and bool(text) for text in descriptions))
|
||||
self.assertEqual(actual["terminal"]["commandDescriptions"], ["IT " + text for text in descriptions])
|
||||
|
||||
def test_preflight_skips_invalid_locales_but_processes_valid_locales(self):
|
||||
for options in [(), ("--refresh",)]:
|
||||
with self.subTest(options=options), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
catalogs = {"en": {"items": ["One", "Two"]},
|
||||
"sk": {"items": ["Human"]},
|
||||
"it": {"items": "Uno"}, "de": {"items": {}}}
|
||||
for lang, data in catalogs.items():
|
||||
path = root / lang / "common.json"
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(data))
|
||||
before = {lang: (root / lang / "common.json").read_bytes() for lang in catalogs}
|
||||
reads = []
|
||||
real_read = MODULE.read_json
|
||||
|
||||
def read(path):
|
||||
reads.append(path.parent.name)
|
||||
return real_read(path)
|
||||
|
||||
def translate(text, lang, *args):
|
||||
self.assertEqual(reads, ["en", "sk", "it", "de"])
|
||||
self.assertEqual(lang, "sk")
|
||||
return "SK " + text
|
||||
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", "sk,it,de",
|
||||
"--sleep", "0", "--save-every", "1", *options]
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "read_json", side_effect=read), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=translate) as provider, \
|
||||
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as writer, \
|
||||
redirect_stderr(output), redirect_stdout(output):
|
||||
self.assertEqual(MODULE.main(), 1)
|
||||
# --refresh without --refresh-keys keeps the existing value.
|
||||
self.assertEqual(json.loads((root / "sk/common.json").read_text()),
|
||||
{"items": ["Human", "SK Two"]})
|
||||
self.assertEqual(provider.call_count, 1)
|
||||
self.assertTrue(all(call.args[0].parent.name == "sk" for call in writer.call_args_list))
|
||||
for lang in ["en", "it", "de"]:
|
||||
self.assertEqual((root / lang / "common.json").read_bytes(), before[lang])
|
||||
self.assertIn("Partial failure: skipped 2 locale(s): it, de", output.getvalue())
|
||||
self.assertNotIn("generated successfully", output.getvalue())
|
||||
|
||||
def test_round_trip_preserves_json_types_and_literal_keys(self):
|
||||
catalog = {"terminal": {"commandDescriptions": ["One", {"nested": ["Two", [], {}]}]},
|
||||
"0": {"a.b": "literal", "": None}, "flags": [True, False, 2, 1.5]}
|
||||
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,338 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "build_web_docs_i18n.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_web_docs_i18n", SCRIPT)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC and SPEC.loader
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class WebDocsI18nTests(unittest.TestCase):
|
||||
def run_generator(self, root, provider):
|
||||
source_root = root / "messages" / "en"
|
||||
messages_root = root / "messages"
|
||||
state_path = messages_root / ".docs-i18n-source-state.json"
|
||||
argv = [
|
||||
str(SCRIPT),
|
||||
"--source-dir",
|
||||
str(source_root),
|
||||
"--messages-dir",
|
||||
str(messages_root),
|
||||
"--languages",
|
||||
"de",
|
||||
"--source-state",
|
||||
str(state_path),
|
||||
"--workers",
|
||||
"1",
|
||||
]
|
||||
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||
MODULE, "provider_function", return_value=provider
|
||||
):
|
||||
return MODULE.main()
|
||||
|
||||
def test_protected_contract_round_trip(self):
|
||||
source = (
|
||||
"Run <code>systemctl restart pveproxy</code> in Proxmox VE, "
|
||||
"then open <link>Settings</link> at {host}."
|
||||
)
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
|
||||
self.assertNotIn("Proxmox VE", protected)
|
||||
self.assertIn("<code>", protected)
|
||||
self.assertIn("</code>", protected)
|
||||
self.assertIn("<link>", protected)
|
||||
self.assertIn("</link>", protected)
|
||||
self.assertNotIn("<code>", mapping.values())
|
||||
self.assertNotIn("</code>", mapping.values())
|
||||
self.assertNotIn("systemctl", mapping.values())
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_human_translation_is_not_pending(self):
|
||||
source = {"title": "Updates", "url": "https://example.com"}
|
||||
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
||||
self.assertEqual(MODULE.pending_leaves(source, target), [])
|
||||
|
||||
def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self):
|
||||
for key, value in (("tone", "amber"), ("variant", "source"),
|
||||
("accent", "border-blue-300 bg-blue-50"),
|
||||
("iconBg", "bg-blue-100 text-blue-700"),
|
||||
("color", "green")):
|
||||
with self.subTest(key=key):
|
||||
self.assertTrue(MODULE.should_copy(value, ("steps", 0, key)))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value})
|
||||
self.assertFalse(MODULE.should_copy("Translation", ("type",)))
|
||||
self.assertFalse(MODULE.should_copy("English", ("lang",)))
|
||||
for value in ("loader", "dedicated"):
|
||||
self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow")))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
|
||||
def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self):
|
||||
for source in ("<code>apt update</code>",
|
||||
"<code>net0</code> / <code>net1</code>.",
|
||||
"/api/task-log/<upid>", "/api/vms/<vmid>/config"):
|
||||
with self.subTest(source=source):
|
||||
self.assertTrue(MODULE.should_copy(source, ("cell",)))
|
||||
self.assertFalse(MODULE.should_copy("Run <code>apt update</code> first.", ("body",)))
|
||||
self.assertFalse(MODULE.should_copy("After running the command, check its output.", ("afterCode",)))
|
||||
self.assertFalse(MODULE.should_copy("Before running the command, read this.", ("beforeCode",)))
|
||||
self.assertTrue(MODULE.should_copy("curl http://localhost:8008/api/health", ("curlCode",)))
|
||||
|
||||
def test_inline_identifiers_and_encoded_delimiters_round_trip(self):
|
||||
source = "Set RS_SKIP_PATHS, call _rs_apply_hot and keep localStorage at <host>."
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "<", ">"):
|
||||
self.assertIn(literal, mapping.values())
|
||||
self.assertNotIn(literal, protected)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_custom_code_spans_keep_commands_literal(self):
|
||||
for tag in ("codeNw", "codeXs"):
|
||||
source = f"Run <{tag}>pct config 100</{tag}>."
|
||||
with self.subTest(tag=tag):
|
||||
protected, mapping = MODULE.protect_rich_tags(source)
|
||||
self.assertEqual(protected, source)
|
||||
self.assertEqual(mapping, {})
|
||||
with self.assertRaisesRegex(ValueError, "literal code changed"):
|
||||
MODULE.validate_contract(source, source.replace("config", "configuration"))
|
||||
with self.assertRaisesRegex(ValueError, "numeric residue"):
|
||||
MODULE.validate_contract(source, source.replace(f"</{tag}>", f"</{tag}>0"))
|
||||
self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100</{tag}>", ("example",)))
|
||||
|
||||
def test_navigation_identifiers_are_distinct_from_visible_labels(self):
|
||||
values = {
|
||||
"categorySlug": ["system", "network"],
|
||||
"menuCmd": ["menu"],
|
||||
"branch": ["main", "develop", "feature/*"],
|
||||
"kind": ["internal", "external"],
|
||||
"key": ["nas", "linux", "windows"],
|
||||
}
|
||||
for key, items in values.items():
|
||||
for value in items:
|
||||
with self.subTest(key=key, value=value):
|
||||
self.assertTrue(MODULE.should_copy(value, ("cards", 0, key)))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
self.assertFalse(MODULE.should_copy("Translation", ("kind",)))
|
||||
self.assertFalse(MODULE.should_copy("Storage", ("branch",)))
|
||||
|
||||
def test_ui_breadcrumb_is_prose_while_paths_remain_literal(self):
|
||||
source = "In the UI: <strong>Datacenter → Backup → Retention</strong>:"
|
||||
self.assertFalse(MODULE.should_copy(source, ("retention", "uiPath")))
|
||||
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("uiPath",)))
|
||||
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("configPath",)))
|
||||
|
||||
def test_bare_sentinel_with_added_digit_is_rejected(self):
|
||||
with mock.patch.object(MODULE, "BARE_SENTINELS", True):
|
||||
protected, mapping = MODULE.protect_text("ProxMenux collects metrics.")
|
||||
token = next(iter(mapping))
|
||||
with self.assertRaisesRegex(ValueError, "changed protected token"):
|
||||
MODULE.restore_text(protected.replace(token, token + "0"), mapping)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), "ProxMenux collects metrics.")
|
||||
|
||||
def test_numeric_residue_outside_intact_literal_is_rejected(self):
|
||||
examples = [
|
||||
('Run <code>pct config</code> first.', 'Spustite <code>pct config</code>0 najprv.'),
|
||||
('<strong><code>Restart=on-failure</code></strong> restarts the service.',
|
||||
'<strong>0<code>Restart=on-failure</code></strong> reštartuje službu.'),
|
||||
]
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, 'numeric residue'):
|
||||
MODULE.validate_contract(source, target)
|
||||
MODULE.validate_contract('The result is <code>echo</code>0.',
|
||||
'Výsledok je <code>echo</code>0.')
|
||||
|
||||
def test_product_and_kernel_module_names_are_protected(self):
|
||||
source = "Flask uses SQLite and journald; rclone works with Coral, gasket, apex and Alpine."
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
for name in ("Flask", "SQLite", "journald", "rclone", "Coral", "gasket", "apex", "Alpine"):
|
||||
self.assertIn(name, mapping.values())
|
||||
self.assertNotIn(name, protected)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_literal_only_changes_sync_without_retranslating_human_prose(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "messages/en/common.json"
|
||||
target = root / "messages/de/common.json"
|
||||
source.parent.mkdir(parents=True)
|
||||
target.parent.mkdir(parents=True)
|
||||
source.write_text(json.dumps({"title": "Updates", "code": "tool --version=2", "tone": "blue"}))
|
||||
target.write_text(json.dumps({"title": "Aktualisierungen", "code": "tool --version=1", "tone": "blau"}))
|
||||
provider = mock.Mock(side_effect=AssertionError("Existing prose must not be retranslated"))
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(json.loads(target.read_text()), {
|
||||
"title": "Aktualisierungen", "code": "tool --version=2", "tone": "blue",
|
||||
})
|
||||
provider.assert_not_called()
|
||||
before = target.stat().st_mtime_ns
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(target.stat().st_mtime_ns, before)
|
||||
provider.assert_not_called()
|
||||
|
||||
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
||||
source = {"title": "Updated installation guidance"}
|
||||
target = {"title": "Vorherige Installationsanleitung"}
|
||||
token = MODULE.leaf_token(("title",))
|
||||
leaves = MODULE.pending_leaves(
|
||||
source,
|
||||
target,
|
||||
forced_tokens={token},
|
||||
)
|
||||
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
|
||||
|
||||
def test_source_fingerprints_change_per_leaf(self):
|
||||
before = MODULE.source_fingerprints({"title": "One", "body": "Same"})
|
||||
after = MODULE.source_fingerprints({"title": "Two", "body": "Same"})
|
||||
self.assertNotEqual(
|
||||
before[MODULE.leaf_token(("title",))],
|
||||
after[MODULE.leaf_token(("title",))],
|
||||
)
|
||||
self.assertEqual(
|
||||
before[MODULE.leaf_token(("body",))],
|
||||
after[MODULE.leaf_token(("body",))],
|
||||
)
|
||||
|
||||
def test_schema_mismatch_detects_removed_keys(self):
|
||||
self.assertFalse(
|
||||
MODULE.schema_matches(
|
||||
{"title": "Title"},
|
||||
{"title": "Título", "removed": "Old"},
|
||||
)
|
||||
)
|
||||
|
||||
def test_schema_mismatch_detects_changed_structural_values(self):
|
||||
self.assertFalse(
|
||||
MODULE.schema_matches(
|
||||
{"enabled": True, "retries": 2},
|
||||
{"enabled": False, "retries": 2},
|
||||
)
|
||||
)
|
||||
|
||||
def test_schema_allows_localized_string_values(self):
|
||||
self.assertTrue(
|
||||
MODULE.schema_matches(
|
||||
{"title": "Updates"},
|
||||
{"title": "Aktualisierungen"},
|
||||
)
|
||||
)
|
||||
|
||||
def test_translatable_rich_tags_use_opaque_names(self):
|
||||
source = (
|
||||
"Keep <strong>this</strong>, <em>that</em> and "
|
||||
"<code>systemctl restart pveproxy</code>."
|
||||
)
|
||||
protected, mapping = MODULE.protect_rich_tags(source)
|
||||
self.assertIn("<pmxrich0000>this</pmxrich0000>", protected)
|
||||
self.assertIn("<pmxrich0001>that</pmxrich0001>", protected)
|
||||
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
|
||||
self.assertEqual(MODULE.restore_rich_tags(protected, mapping), source)
|
||||
|
||||
def test_source_schema_drives_output(self):
|
||||
source = {"title": "Title", "items": ["One", "Two"]}
|
||||
target = {
|
||||
"title": "Título",
|
||||
"items": ["Uno", "Dos", "Obsoleto"],
|
||||
"removed": "Old",
|
||||
}
|
||||
translated = {
|
||||
("items", 0): "Uno",
|
||||
("items", 1): "Dos",
|
||||
}
|
||||
self.assertEqual(
|
||||
MODULE.merge_tree(source, target, translated),
|
||||
{"title": "Título", "items": ["Uno", "Dos"]},
|
||||
)
|
||||
|
||||
def test_translation_memory_ignores_conflicts(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source_root = root / "messages" / "en"
|
||||
target_root = root / "messages" / "es"
|
||||
source_root.mkdir(parents=True)
|
||||
target_root.mkdir(parents=True)
|
||||
(source_root / "one.json").write_text(
|
||||
json.dumps({"label": "Settings"}), encoding="utf-8"
|
||||
)
|
||||
(target_root / "one.json").write_text(
|
||||
json.dumps({"label": "Ajustes"}), encoding="utf-8"
|
||||
)
|
||||
(source_root / "two.json").write_text(
|
||||
json.dumps({"label": "Settings"}), encoding="utf-8"
|
||||
)
|
||||
(target_root / "two.json").write_text(
|
||||
json.dumps({"label": "Configuración"}), encoding="utf-8"
|
||||
)
|
||||
memory = MODULE.collect_memory(source_root, root / "messages", "es")
|
||||
self.assertNotIn("Settings", memory)
|
||||
|
||||
def test_source_state_retranslates_an_existing_changed_leaf(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source_path = root / "messages" / "en" / "page.json"
|
||||
target_path = root / "messages" / "de" / "page.json"
|
||||
source_path.parent.mkdir(parents=True)
|
||||
target_path.parent.mkdir(parents=True)
|
||||
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
|
||||
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
|
||||
|
||||
self.assertEqual(
|
||||
self.run_generator(root, lambda *_: self.fail("provider called during baseline")),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(read_json_file(target_path), {"title": "Ursprünglich"})
|
||||
|
||||
source_path.write_text(json.dumps({"title": "Changed"}), encoding="utf-8")
|
||||
calls = []
|
||||
|
||||
def provider(text, language):
|
||||
calls.append((text, language))
|
||||
return "Geändert"
|
||||
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(calls, [("Changed", "de")])
|
||||
self.assertEqual(read_json_file(target_path), {"title": "Geändert"})
|
||||
|
||||
def test_new_leaf_preserves_translation_supplied_with_source_change(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source_path = root / "messages" / "en" / "page.json"
|
||||
target_path = root / "messages" / "de" / "page.json"
|
||||
source_path.parent.mkdir(parents=True)
|
||||
target_path.parent.mkdir(parents=True)
|
||||
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
|
||||
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
|
||||
self.assertEqual(self.run_generator(root, lambda *_: "unused"), 0)
|
||||
|
||||
source_path.write_text(
|
||||
json.dumps({"title": "Original", "new": "New text"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
target_path.write_text(
|
||||
json.dumps({"title": "Ursprünglich", "new": "Neuer Text"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.run_generator(root, lambda *_: self.fail("manual translation overwritten")),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
read_json_file(target_path),
|
||||
{"title": "Ursprünglich", "new": "Neuer Text"},
|
||||
)
|
||||
|
||||
|
||||
def read_json_file(path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""The Changes view files a change under the post-install section by the name
|
||||
its script records itself with. Both post-install scripts have to be known to
|
||||
it, or what one of them changes is shown as the work of an unnamed script."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
VIEW = (ROOT / "AppImage/components/audit-changes.tsx").read_text()
|
||||
SCRIPTS = ("scripts/post_install/auto_post_install.sh", "scripts/post_install/customizable_post_install.sh")
|
||||
|
||||
|
||||
class PostInstallSourcesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
declared = re.search(r"const POST_INSTALL_SOURCES = new Set\(\[([^\]]*)\]\)", VIEW)
|
||||
self.assertIsNotNone(declared)
|
||||
self.known = set(re.findall(r'"([^"]+)"', declared.group(1)))
|
||||
|
||||
def test_every_post_install_script_is_known_by_the_name_it_records(self):
|
||||
for script in SCRIPTS:
|
||||
with self.subTest(script=script):
|
||||
source = re.search(r'^SCRIPT_SOURCE="([^"]+)"', (ROOT / script).read_text(), re.M)
|
||||
self.assertIsNotNone(source)
|
||||
self.assertIn(source.group(1), self.known)
|
||||
|
||||
def test_no_name_is_listed_that_no_script_records(self):
|
||||
recorded = {re.search(r'^SCRIPT_SOURCE="([^"]+)"', (ROOT / script).read_text(), re.M).group(1)
|
||||
for script in SCRIPTS}
|
||||
self.assertEqual(self.known, recorded)
|
||||
|
||||
def test_figurine_and_fastfetch_have_a_name_in_the_view(self):
|
||||
for function in ("configure_figurine", "configure_fastfetch"):
|
||||
self.assertRegex(VIEW, rf'\b{function}: "')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Exercise the Cloud-Init menu without network access or guest creation."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SELECTOR = ROOT / "scripts/vm/select_linux_iso.sh"
|
||||
|
||||
|
||||
class CloudInitLauncherTests(unittest.TestCase):
|
||||
def run_menu(self, selection="2", body=None, curl_exit=0, dialog_exit=0,
|
||||
installer_exit=0, stdin="\n"):
|
||||
source = SELECTOR.read_text(encoding="utf-8")
|
||||
match = re.search(r"(?ms)^function select_linux_cloudinit\(\) \{.*?^\}", source)
|
||||
assert match is not None, "Cloud-Init menu function not found"
|
||||
with tempfile.TemporaryDirectory(prefix="cloudinit-launcher-") as temporary:
|
||||
work = Path(temporary)
|
||||
(work / "menus").mkdir()
|
||||
(work / "menus/create_vm_menu.sh").write_text(
|
||||
'printf "menu-returned\\n" >> "$ACTION_LOG"\n', encoding="utf-8")
|
||||
environment = os.environ.copy()
|
||||
environment.update({
|
||||
"LOCAL_SCRIPTS": str(work), "SELECTION": selection,
|
||||
"DIALOG_EXIT": str(dialog_exit), "CURL_EXIT": str(curl_exit),
|
||||
"INSTALLER_EXIT": str(installer_exit),
|
||||
"CURL_BODY": body if body is not None else
|
||||
'printf "helper-executed\\n" >> "$ACTION_LOG"\nexit "$INSTALLER_EXIT"\n',
|
||||
"ACTION_LOG": str(work / "actions"),
|
||||
"CURL_ARGS": str(work / "curl-args"),
|
||||
"DIALOG_LOG": str(work / "dialog-args"),
|
||||
})
|
||||
prelude = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_success() { printf 'continue-prompt\n' >> "$ACTION_LOG"; }
|
||||
whiptail() { printf 'website-prompt\n' >> "$ACTION_LOG"; }
|
||||
dialog() {
|
||||
printf '%s\n' "$@" >> "$DIALOG_LOG"
|
||||
if [[ " $* " == *" --menu "* ]]; then
|
||||
printf '%s' "$SELECTION" >&2
|
||||
return "$DIALOG_EXIT"
|
||||
fi
|
||||
}
|
||||
curl() {
|
||||
printf '%s\n' "$@" > "$CURL_ARGS"
|
||||
printf '%s' "$CURL_BODY"
|
||||
return "$CURL_EXIT"
|
||||
}
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c",
|
||||
prelude + match.group() + "\nselect_linux_cloudinit\n"],
|
||||
input=stdin, capture_output=True, text=True, env=environment,
|
||||
timeout=5)
|
||||
def contents(name):
|
||||
path = work / name
|
||||
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
return result, contents("curl-args"), contents("dialog-args"), contents("actions")
|
||||
|
||||
def test_debian_uses_the_maintained_generic_installer(self):
|
||||
result, curl_args, _, actions = self.run_menu()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(curl_args.splitlines()[-1],
|
||||
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/debian-vm.sh")
|
||||
self.assertIn("helper-executed", actions)
|
||||
|
||||
|
||||
def test_ubuntu_uses_one_generic_entry_without_obsolete_release_labels(self):
|
||||
result, curl_args, menu, actions = self.run_menu(selection="3")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(curl_args.splitlines()[-1],
|
||||
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu-vm.sh")
|
||||
self.assertIn("helper-executed", actions)
|
||||
self.assertEqual(menu.count("(Cloud-Init automated)"), 3)
|
||||
for obsolete in ("Debian 13", "Debian 12", "Ubuntu 22.04", "Ubuntu 24.04",
|
||||
"Ubuntu 24.10", "Ubuntu 25.04"):
|
||||
self.assertNotIn(obsolete, menu)
|
||||
|
||||
|
||||
def test_failed_or_empty_download_never_runs_a_script(self):
|
||||
for body, exit_code in (("404: Not Found", 22),
|
||||
('printf "helper-executed\\n" >> "$ACTION_LOG"', 28),
|
||||
("", 0), (" \n\t", 0)):
|
||||
with self.subTest(body=body, exit_code=exit_code):
|
||||
result, _, messages, actions = self.run_menu(body=body, curl_exit=exit_code)
|
||||
self.assertNotIn("helper-executed", actions)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Error: Failed to download the script.", messages)
|
||||
self.assertNotIn("menu-returned", actions)
|
||||
|
||||
|
||||
def test_installer_failure_returns_its_status_without_success_navigation(self):
|
||||
result, _, _, actions = self.run_menu(installer_exit=17)
|
||||
self.assertEqual(result.returncode, 17, result.stderr)
|
||||
self.assertIn("helper-executed", actions)
|
||||
self.assertNotIn("continue-prompt", actions)
|
||||
self.assertNotIn("website-prompt", actions)
|
||||
self.assertNotIn("menu-returned", actions)
|
||||
|
||||
|
||||
def test_arch_and_successful_menu_navigation_are_preserved(self):
|
||||
result, curl_args, _, actions = self.run_menu(selection="1")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(curl_args.splitlines()[-1],
|
||||
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/archlinux-vm.sh")
|
||||
self.assertEqual(actions.splitlines(),
|
||||
["helper-executed", "continue-prompt", "website-prompt", "menu-returned"])
|
||||
arguments = curl_args.splitlines()
|
||||
self.assertIn("-fsSL", arguments)
|
||||
self.assertEqual(arguments[arguments.index("--connect-timeout") + 1], "5")
|
||||
self.assertEqual(arguments[arguments.index("--max-time") + 1], "60")
|
||||
|
||||
def test_return_and_cancel_do_not_download_or_execute(self):
|
||||
for selection, status, expected in (("4", 0, 1), ("2", 1, 0), ("2", 255, 0)):
|
||||
with self.subTest(selection=selection, status=status):
|
||||
result, curl_args, _, actions = self.run_menu(
|
||||
selection=selection, dialog_exit=status)
|
||||
self.assertEqual(result.returncode, expected, result.stderr)
|
||||
self.assertEqual(curl_args, "")
|
||||
self.assertEqual(actions, "")
|
||||
|
||||
def test_unknown_selection_is_rejected(self):
|
||||
result, curl_args, _, actions = self.run_menu(selection="999")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(curl_args, "")
|
||||
self.assertEqual(actions, "")
|
||||
|
||||
def test_installer_keeps_interactive_standard_input(self):
|
||||
body = 'IFS= read -r answer; printf "input:%s\\n" "$answer" >> "$ACTION_LOG"\n'
|
||||
result, _, _, actions = self.run_menu(body=body, stdin="answer with spaces\n\n")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("input:answer with spaces\n", actions)
|
||||
self.assertIn("menu-returned", actions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Frozen conservative recovery: update the fixture only after explicit review.
|
||||
|
||||
Run offline with Python unittest and Node >=22.14 on PATH (or NODE_BINARY).
|
||||
The consumer test executes only extracted pure lookup functions, not the UI.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from unittest import mock
|
||||
|
||||
from test_build_i18n_messages import MODULE, SCRIPT
|
||||
|
||||
ROOT = SCRIPT.parents[2]
|
||||
FIXTURE = json.loads((Path(__file__).parent / "fixtures/command_descriptions.json").read_text())
|
||||
|
||||
|
||||
def require_node():
|
||||
diagnostic = "Node >=22.14 is required; install Node 22 LTS or set NODE_BINARY to its executable"
|
||||
node = os.environ.get("NODE_BINARY") or shutil.which("node")
|
||||
if not node:
|
||||
raise AssertionError(diagnostic)
|
||||
try:
|
||||
result = subprocess.run([node, "--version"], capture_output=True, text=True)
|
||||
except OSError as exc:
|
||||
raise AssertionError(f"{diagnostic}; cannot execute {node}: {exc}") from exc
|
||||
version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", result.stdout.strip())
|
||||
if result.returncode or not version or tuple(map(int, version.groups())) < (22, 14, 0):
|
||||
raise AssertionError(f"{diagnostic}; found {result.stdout.strip() or result.stderr.strip()!r}")
|
||||
return node
|
||||
|
||||
|
||||
def catalog(lang):
|
||||
return json.loads((ROOT / f"AppImage/messages/{lang}/common.json").read_text())
|
||||
|
||||
|
||||
class NodeRuntimeTests(unittest.TestCase):
|
||||
def test_missing_node_reports_setup_instructions(self):
|
||||
with mock.patch.dict(os.environ, {"PATH": "", "NODE_BINARY": ""}):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
|
||||
require_node()
|
||||
|
||||
def test_invalid_override_reports_setup_instructions(self):
|
||||
with mock.patch.dict(os.environ, {"NODE_BINARY": "/nonexistent/f05-node"}):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
|
||||
require_node()
|
||||
|
||||
def test_older_node_reports_detected_version(self):
|
||||
with mock.patch.dict(os.environ, {"NODE_BINARY": "node"}), \
|
||||
mock.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess(
|
||||
["node", "--version"], 0, "v20.19.0\n", "")):
|
||||
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*v20\.19\.0"):
|
||||
require_node()
|
||||
|
||||
|
||||
class CommandDescriptionsTests(unittest.TestCase):
|
||||
def test_real_catalogs_exact_indexed_recovery_and_source_order(self):
|
||||
self.assertEqual(catalog("en")["terminal"]["commandDescriptions"], FIXTURE["english"])
|
||||
consumer = (ROOT / "AppImage/components/terminal-panel.tsx").read_text()
|
||||
commands = re.search(r"const proxmoxCommands = (\[.*?\])", consumer, re.S).group(1)
|
||||
commands = json.loads(re.sub(r",\s*\]$", "]", commands))
|
||||
self.assertEqual(commands, FIXTURE["commands"])
|
||||
self.assertIn('desc: t(`terminal.commandDescriptions.${index}`)', consumer)
|
||||
for lang, expected in FIXTURE["locales"].items():
|
||||
with self.subTest(locale=lang):
|
||||
actual = catalog(lang)["terminal"]["commandDescriptions"]
|
||||
self.assertIsInstance(actual, list)
|
||||
self.assertEqual(len(actual), len(commands))
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertTrue(all(isinstance(x, str) and x for x in actual))
|
||||
# Explicit English recovery values are frozen locale text, not live fallback.
|
||||
for index in FIXTURE["fallback_indices"]:
|
||||
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
|
||||
FIXTURE["locales"]["sv"][index])
|
||||
|
||||
def assert_completed_catalogs_are_stable(self, *, future_messages=None):
|
||||
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
|
||||
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
|
||||
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
|
||||
locales = ["en", *languages.split(",")]
|
||||
shipped = {ROOT / f"AppImage/messages/{lang}/common.json": catalog(lang) for lang in locales}
|
||||
original_bytes = {path: path.read_bytes() for path in shipped}
|
||||
|
||||
def strings(node, path=()):
|
||||
# Independent traversal: every existing nonempty string, including
|
||||
# explicit English and obsolete human keys, must survive completion.
|
||||
if isinstance(node, str) and node:
|
||||
yield path, node
|
||||
elif isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
yield from strings(value, (*path, key))
|
||||
elif isinstance(node, list):
|
||||
for index, value in enumerate(node):
|
||||
yield from strings(value, (*path, index))
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
for path in shipped:
|
||||
destination = root / path.parent.name / "common.json"
|
||||
destination.parent.mkdir()
|
||||
shutil.copyfile(path, destination)
|
||||
if future_messages is not None:
|
||||
source = json.loads((root / "en/common.json").read_text())
|
||||
source["futureFeatureFixture"] = future_messages
|
||||
(root / "en/common.json").write_text(json.dumps(source))
|
||||
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
||||
output = io.StringIO()
|
||||
# Establish steady state with the real generator and a deterministic
|
||||
# offline provider. Translation happens only in disposable catalogs;
|
||||
# adding an EN-only feature never needs a hand-maintained seed list.
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=lambda text, lang, *_: f"TEST {lang} {text}") as seed, \
|
||||
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as initial_writer, \
|
||||
redirect_stdout(output), redirect_stderr(output):
|
||||
self.assertEqual(MODULE.main(), 0, output.getvalue())
|
||||
self.assertNotRegex(output.getvalue(), r"(?m)^Completed with [0-9]+ translation failures")
|
||||
self.assertTrue(all(call.args[0].is_relative_to(root) for call in initial_writer.call_args_list))
|
||||
for path, original in shipped.items():
|
||||
completed = json.loads((root / path.parent.name / "common.json").read_text())
|
||||
for keys, value in strings(original):
|
||||
actual = completed
|
||||
for key in keys:
|
||||
actual = actual[key]
|
||||
self.assertEqual(actual, value, f"{path.parent.name}: {keys}")
|
||||
# The recovered arrays remain exact, including frozen English.
|
||||
if path.parent.name != "en":
|
||||
self.assertEqual(completed["terminal"]["commandDescriptions"],
|
||||
FIXTURE["locales"].get(path.parent.name,
|
||||
original["terminal"]["commandDescriptions"]))
|
||||
if future_messages is not None and path.parent.name != "en":
|
||||
self.assertEqual(completed["futureFeatureFixture"], {
|
||||
"title": f"TEST {path.parent.name} Future feature {{count}}",
|
||||
"items": [f"TEST {path.parent.name} First item", {"label": f"TEST {path.parent.name} Second item"}],
|
||||
"enabled": False, "count": 2, "empty": [],
|
||||
})
|
||||
if future_messages is not None:
|
||||
self.assertGreaterEqual(seed.call_count, 3 * len(languages.split(",")))
|
||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||
output = io.StringIO()
|
||||
# Keep the original strict assertion: once completed, any provider
|
||||
# call or catalog write is a regression. No production locale edits.
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
|
||||
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
|
||||
redirect_stdout(output), redirect_stderr(output):
|
||||
result = MODULE.main()
|
||||
self.assertEqual(result, 0, output.getvalue())
|
||||
provider.assert_not_called()
|
||||
writer.assert_not_called()
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||
for lang in languages.split(","):
|
||||
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
|
||||
self.assertEqual(original_bytes, {path: path.read_bytes() for path in shipped})
|
||||
|
||||
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
|
||||
self.assert_completed_catalogs_are_stable()
|
||||
|
||||
def test_new_english_feature_preserves_curated_values_and_reruns_without_writes(self):
|
||||
self.assert_completed_catalogs_are_stable(future_messages={
|
||||
"title": "Future feature {count}",
|
||||
"items": ["First item", {"label": "Second item"}],
|
||||
"enabled": False, "count": 2, "empty": [],
|
||||
})
|
||||
|
||||
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
|
||||
node = require_node()
|
||||
source = (ROOT / "AppImage/lib/i18n/provider.tsx").read_text()
|
||||
functions = source[source.index("function getMessage("):source.index("export function I18nProvider(")]
|
||||
lookup = re.search(r" const localized =.*?return interpolate\(.*?\)\n", source, re.S).group(0)
|
||||
catalogs = {lang: catalog(lang) for lang in ["en", *FIXTURE["locales"]]}
|
||||
script = ('import assert from "node:assert/strict";\n'
|
||||
'type MessageTree = Record<string, unknown>;\n'
|
||||
'type TranslationParams = Record<string, string | number>;\n' + functions +
|
||||
'\nconst MESSAGE_CATALOG = ' + json.dumps(catalogs) + ';\n' +
|
||||
'function t(language: string, key: string, params?: TranslationParams) {\n' + lookup + '}\n' +
|
||||
'const fixture = ' + json.dumps(FIXTURE) + ';\n' + '''
|
||||
fixture.english.forEach((text, i) => {
|
||||
assert.equal(t("en", `terminal.commandDescriptions.${i}`), text);
|
||||
});
|
||||
for (const [lang, texts] of Object.entries(fixture.locales)) {
|
||||
texts.forEach((text, i) => {
|
||||
const key = `terminal.commandDescriptions.${i}`;
|
||||
assert.equal(getMessage(MESSAGE_CATALOG[lang], key), text);
|
||||
assert.equal(t(lang, key), text);
|
||||
});
|
||||
}
|
||||
for (const i of fixture.fallback_indices) {
|
||||
const key = `terminal.commandDescriptions.${i}`;
|
||||
const selected = MESSAGE_CATALOG.sv.terminal.commandDescriptions[i];
|
||||
MESSAGE_CATALOG.en.terminal.commandDescriptions[i] = "CHANGED EN";
|
||||
assert.equal(t("sv", key), selected); // Explicit English is a locale value.
|
||||
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = null;
|
||||
assert.equal(t("sv", key), "CHANGED EN"); // Null would dynamically fall back.
|
||||
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = "";
|
||||
assert.equal(t("sv", key), ""); // Empty strings do NOT fall back.
|
||||
}
|
||||
assert.equal(t("sv", "missing.key"), "missing.key");
|
||||
console.log("390 indexed consumer lookups; 15 explicit-English/null/empty cases passed");
|
||||
''')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "consumer.ts"
|
||||
path.write_text(script)
|
||||
result = subprocess.run([node, "--experimental-strip-types", str(path)],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("390 indexed consumer lookups", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Keep the canonical conduct policy and its English website source in sync."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DocumentationMarkdownTests(unittest.TestCase):
|
||||
def test_english_conduct_body_matches_canonical_policy(self):
|
||||
source = json.loads((ROOT / 'web/messages/en/docs/about/code-of-conduct.json').read_text(encoding='utf-8'))
|
||||
self.assertEqual(source['contentMarkdown'], (ROOT / 'CODE_OF_CONDUCT.md').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Keep machine generation explicit without losing automatic validation."""
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DocumentationReviewWorkflowTests(unittest.TestCase):
|
||||
def workflow(self, name):
|
||||
return yaml.load(
|
||||
(ROOT / '.github/workflows' / name).read_text(encoding='utf-8'),
|
||||
Loader=yaml.BaseLoader,
|
||||
)
|
||||
|
||||
def test_generation_requires_explicit_dispatch(self):
|
||||
triggers = self.workflow('build-web-docs-i18n.yml')['on']
|
||||
self.assertEqual(set(triggers), {'workflow_dispatch'})
|
||||
|
||||
def test_website_changes_keep_automatic_offline_validation(self):
|
||||
triggers = self.workflow('test-i18n.yml')['on']
|
||||
for event in ('pull_request', 'push'):
|
||||
with self.subTest(event=event):
|
||||
self.assertIn('web/**', triggers[event]['paths'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Inert fencing wording contract: execute extracted producers and template renderer."""
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import unittest
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
EVENTS = ROOT / 'AppImage/scripts/notification_events.py'
|
||||
TEMPLATES = ROOT / 'AppImage/scripts/notification_templates.py'
|
||||
MESSAGES = ROOT / 'AppImage/messages'
|
||||
FIELDS = ('title', 'body', 'label')
|
||||
|
||||
|
||||
def extract_method(path, owner, method, namespace):
|
||||
tree = ast.parse(path.read_text(encoding='utf-8'))
|
||||
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
|
||||
node = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == method)
|
||||
node.decorator_list = []
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), namespace)
|
||||
return namespace[method]
|
||||
|
||||
|
||||
def notification_renderer(catalogs):
|
||||
tree = ast.parse(TEMPLATES.read_text(encoding='utf-8'))
|
||||
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
|
||||
node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'render_template')
|
||||
|
||||
def lookup(obj, dotted):
|
||||
for part in dotted.split('.'):
|
||||
obj = obj.get(part) if isinstance(obj, dict) else None
|
||||
return obj
|
||||
|
||||
namespace = {'TEMPLATES': templates, 'Dict': dict, 'Any': object,
|
||||
'_get_hostname': lambda: 'node-a',
|
||||
'_load_runtime_catalog': lambda lang: catalogs[lang]['runtime']['notifications'],
|
||||
'_catalog_value': lookup, 'runtime_message': lambda key, lang, **kw: key,
|
||||
'time': time, 're': re}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(TEMPLATES), 'exec'), namespace)
|
||||
return templates, namespace['render_template']
|
||||
|
||||
|
||||
class FencingWordingTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.catalogs = {p.parent.name: json.loads(p.read_text(encoding='utf-8'))
|
||||
for p in MESSAGES.glob('*/common.json')}
|
||||
cls.templates, render = notification_renderer(cls.catalogs)
|
||||
cls.render = staticmethod(render)
|
||||
|
||||
def test_webhook_fencing_renders_without_split_brain_or_quorum_diagnosis(self):
|
||||
namespace: dict[str, Any] = {'re': re}
|
||||
classify = extract_method(EVENTS, 'ProxmoxHookWatcher', '_classify_pve', namespace)
|
||||
severity = extract_method(EVENTS, 'ProxmoxHookWatcher', '_map_severity', namespace)
|
||||
namespace['capture_journal_context'] = lambda **kw: ''
|
||||
class Event:
|
||||
def __init__(self, **kw):
|
||||
self.__dict__.update(kw)
|
||||
self.event_id = 'inert-id'
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, item): self.items.append(item)
|
||||
namespace['NotificationEvent'] = Event
|
||||
webhook = extract_method(EVENTS, 'ProxmoxHookWatcher', 'process_webhook', namespace)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = classify
|
||||
_map_severity = staticmethod(severity)
|
||||
def __init__(self): self._queue = Queue()
|
||||
receiver = Receiver()
|
||||
for fields in ({'type': 'fencing'}, {'type': 'fencing', 'quorum': 'lost'}):
|
||||
result = webhook(receiver, {'fields': fields, 'severity': 'warning',
|
||||
'title': 'Fence requested', 'message': 'Check node state'})
|
||||
self.assertEqual(result['event_type'], 'split_brain')
|
||||
event = receiver._queue.items.pop()
|
||||
self.assertEqual(event.severity, 'WARNING')
|
||||
self.assertNotIn('quorum', event.data)
|
||||
for lang, catalog in self.catalogs.items():
|
||||
rendered = self.render(event.event_type, event.data, lang)
|
||||
expected = catalog['runtime']['notifications']['templates']['split_brain']
|
||||
self.assertEqual(rendered['title'], expected['title'].format(hostname='node-a'), lang)
|
||||
# The body names the source event, as received, and adds no diagnosis of its own.
|
||||
self.assertEqual(rendered['body'], expected['body'].replace('{reason}', event.data['reason']), lang)
|
||||
self.assertIn('Check node state', rendered['body'], lang)
|
||||
self.assertNotIn('split-brain', '\n'.join((rendered['title'], rendered['body'])).lower(), lang)
|
||||
self.assertNotIn('quorum', rendered['body'].lower(), lang)
|
||||
self.assertEqual(webhook(receiver, {'type': 'fencing', 'severity': 'warning',
|
||||
'title': 'Unclassified', 'message': 'Other'})['event_type'], 'system_problem')
|
||||
self.assertEqual(self.templates['split_brain']['group'], 'cluster')
|
||||
self.assertIs(self.templates['split_brain']['default_enabled'], True)
|
||||
|
||||
def test_journal_family_uses_same_truthful_wording(self):
|
||||
tree = ast.parse(EVENTS.read_text(encoding='utf-8'))
|
||||
owner = next(n.name for n in tree.body if isinstance(n, ast.ClassDef)
|
||||
and any(isinstance(m, ast.FunctionDef) and m.name == '_check_cluster_events' for m in n.body))
|
||||
namespace: dict[str, Any] = {'re': re}
|
||||
method = extract_method(EVENTS, owner, '_check_cluster_events', namespace)
|
||||
class Journal:
|
||||
_hostname = 'node-a'
|
||||
def __init__(self): self.events = []
|
||||
def _emit(self, *args, **kwargs): self.events.append((args, kwargs))
|
||||
for message in ('fencing required for node', 'cluster partition suspected', 'split-brain reported'):
|
||||
journal = Journal()
|
||||
method(journal, message, 'syslog')
|
||||
args, _ = journal.events[0]
|
||||
self.assertEqual(args[0], 'split_brain')
|
||||
for lang, catalog in self.catalogs.items():
|
||||
rendered = self.render(args[0], args[2], lang)
|
||||
body = catalog['runtime']['notifications']['templates']['split_brain']['body']
|
||||
self.assertEqual(rendered['body'], body.replace('{reason}', message), lang)
|
||||
journal = Journal()
|
||||
method(journal, 'quorum lost', 'syslog')
|
||||
self.assertEqual(journal.events[0][0][0], 'node_disconnect')
|
||||
|
||||
def test_english_family_and_settings_label_align_without_changing_key(self):
|
||||
en = self.catalogs['en']
|
||||
self.assertEqual(self.templates['split_brain']['label'], 'Cluster event')
|
||||
self.assertEqual(en['runtime']['notifications']['templates']['split_brain']['label'], 'Cluster event')
|
||||
self.assertEqual(en['settings']['notifications']['eventTypes']['split_brain'], 'Cluster events')
|
||||
settings = (ROOT / 'AppImage/components/notification-settings.tsx').read_text(encoding='utf-8')
|
||||
self.assertIn('settings.notifications.eventTypes.${evt.type}', settings)
|
||||
self.assertIn('evt.title : translated', settings)
|
||||
for field in FIELDS:
|
||||
self.assertEqual(en['runtime']['notifications']['templates']['split_brain'][field],
|
||||
self.templates['split_brain'][field])
|
||||
|
||||
def test_every_shipped_locale_renders_cluster_event_without_diagnosis(self):
|
||||
expected = {
|
||||
'de': ('{hostname}: Clusterereignis gemeldet', 'Ein Clusterereignis wurde gemeldet:\n{reason}', 'Clusterereignis', 'Clusterereignisse'),
|
||||
'es': ('{hostname}: evento del clúster notificado', 'Se ha notificado un evento del clúster:\n{reason}', 'Evento del clúster', 'Eventos del clúster'),
|
||||
'fr': ('{hostname}\u00a0: événement du cluster signalé', 'Un événement du cluster a été signalé\u00a0:\n{reason}', 'Événement du cluster', 'Événements du cluster'),
|
||||
'it': ('{hostname}: evento del cluster segnalato', 'È stato segnalato un evento del cluster:\n{reason}', 'Evento del cluster', 'Eventi del cluster'),
|
||||
'pt': ('{hostname}: evento do cluster comunicado', 'Foi comunicado um evento do cluster:\n{reason}', 'Evento do cluster', 'Eventos do cluster'),
|
||||
'sk': ('{hostname}: hlásená udalosť klastra', 'Bola hlásená udalosť klastra:\n{reason}', 'Udalosť klastra', 'Udalosti klastra'),
|
||||
'sv': ('{hostname}: klusterhändelse rapporterad', 'En klusterhändelse har rapporterats:\n{reason}', 'Klusterhändelse', 'Klusterhändelser'),
|
||||
}
|
||||
self.assertEqual(set(self.catalogs), {'en', *expected})
|
||||
for lang, (title, body, label, settings) in expected.items():
|
||||
with self.subTest(lang=lang):
|
||||
leaves = self.catalogs[lang]['runtime']['notifications']['templates']['split_brain']
|
||||
self.assertEqual((leaves['title'], leaves['body'], leaves['label'],
|
||||
self.catalogs[lang]['settings']['notifications']['eventTypes']['split_brain']),
|
||||
(title, body, label, settings))
|
||||
result = self.render('split_brain', {'reason': 'fencing node node-b'}, lang)
|
||||
self.assertEqual(result['title'], title.format(hostname='node-a'))
|
||||
self.assertEqual(result['body'], body.replace('{reason}', 'fencing node node-b'))
|
||||
self.assertNotIn('quorum', result['body'].lower())
|
||||
|
||||
def test_fallback_and_translated_lookup_are_real_for_missing_and_synthetic_values(self):
|
||||
import copy
|
||||
catalogs = copy.deepcopy(self.catalogs)
|
||||
for field in FIELDS:
|
||||
catalogs['it']['runtime']['notifications']['templates']['split_brain'].pop(field)
|
||||
_, render = notification_renderer(catalogs)
|
||||
self.assertEqual(render('split_brain', {'reason': 'fencing node node-b'}, 'it')['body'],
|
||||
'A cluster event was reported:\nfencing node node-b')
|
||||
catalogs['it']['runtime']['notifications']['templates']['split_brain']['body'] = 'Evento del cluster segnalato.'
|
||||
_, render = notification_renderer(catalogs)
|
||||
self.assertEqual(render('split_brain', {}, 'it')['body'], 'Evento del cluster segnalato.')
|
||||
for lang, catalog in self.catalogs.items():
|
||||
if lang == 'en': continue
|
||||
leaves = catalog['runtime']['notifications']['templates']['split_brain']
|
||||
self.assertTrue(all(leaves[field] for field in FIELDS), lang)
|
||||
self.assertTrue(catalog['settings']['notifications']['eventTypes']['split_brain'], lang)
|
||||
self.assertNotEqual(leaves['body'], self.catalogs['en']['runtime']['notifications']['templates']['split_brain']['body'], lang)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""The menus that install drivers inside a container leave out the ones
|
||||
created from an OCI image: those are changed from OCI manager Apps."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENUS = ('scripts/gpu_tpu/add_gpu_lxc.sh', 'scripts/gpu_tpu/install_coral_lxc.sh')
|
||||
ORDINARY = "arch: amd64\ncores: 2\nhostname: debian\nostype: debian\nrootfs: local-lvm:vm-111-disk-0,size=8G\n"
|
||||
OCI = ORDINARY + "entrypoint: /init\nlxc.signal.halt: SIGTERM\n"
|
||||
|
||||
|
||||
def function(path, name):
|
||||
source = (ROOT / path).read_text()
|
||||
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
|
||||
|
||||
|
||||
class GpuMenusHideOci(TestCase):
|
||||
def is_oci(self, config):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
(Path(folder) / '111.conf').write_text(config)
|
||||
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
|
||||
return subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 111'], capture_output=True).returncode == 0
|
||||
|
||||
def test_a_container_from_an_oci_image_is_told_apart_by_its_configuration(self):
|
||||
self.assertTrue(self.is_oci(OCI))
|
||||
self.assertTrue(self.is_oci(ORDINARY + "cmd: nginx -g 'daemon off;'\n"))
|
||||
self.assertFalse(self.is_oci(ORDINARY))
|
||||
# A setting that only mentions the word is not an entrypoint.
|
||||
self.assertFalse(self.is_oci(ORDINARY + "description: entrypoint: none\n"))
|
||||
|
||||
def test_a_container_without_configuration_is_not_one(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
|
||||
self.assertNotEqual(subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 999'],
|
||||
capture_output=True).returncode, 0)
|
||||
|
||||
def test_both_menus_skip_them_and_say_so(self):
|
||||
for menu in MENUS:
|
||||
body = function(menu, 'select_container')
|
||||
self.assertLess(body.index('pmx_lxc_is_oci "$ctid"'), body.index('menu_items+=('), menu)
|
||||
self.assertIn('Containers created from an OCI image are not listed:', body, menu)
|
||||
self.assertEqual(body.count('${oci_note}'), 2, menu)
|
||||
|
||||
|
||||
SWITCHES = (('scripts/gpu_tpu/switch_gpu_mode.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
|
||||
('scripts/gpu_tpu/switch_gpu_mode_direct.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
|
||||
('scripts/gpu_tpu/add_gpu_vm.sh', 'cleanup_lxc_configs', None))
|
||||
|
||||
|
||||
class GpuToVmKeepsOciConfiguration(TestCase):
|
||||
"""A GPU that moves to a VM stops the containers that use it. One created
|
||||
from an OCI image keeps its configuration, which OCI manager Apps records."""
|
||||
|
||||
def test_an_oci_container_is_never_edited(self):
|
||||
for script, apply, _ in SWITCHES:
|
||||
body = function(script, apply)
|
||||
guard = body.index('pmx_lxc_is_oci "$ctid"')
|
||||
self.assertLess(guard, body.index('action="keep_gpu_disable_onboot"'), script)
|
||||
removal = body.index('"$action" == "remove_gpu_keep_onboot"')
|
||||
self.assertLess(guard, removal, script)
|
||||
# The only edit of a configuration hangs from the per-container action.
|
||||
self.assertEqual(len(re.findall(r'_from_lxc_conf "\$', body)), 1, script)
|
||||
edit = body.index('_from_lxc_conf "$')
|
||||
condition = body.rindex('if [[', 0, edit)
|
||||
self.assertIn('"$action" == "remove_gpu_keep_onboot"', body[condition:edit], script)
|
||||
|
||||
def test_the_list_names_them_and_nothing_is_asked_when_all_are_oci(self):
|
||||
for script, _, prompt in SWITCHES:
|
||||
source = (ROOT / script).read_text() if prompt is None else function(script, prompt)
|
||||
self.assertIn('", OCI"', source, script)
|
||||
self.assertIn('OCI containers keep their configuration', source, script)
|
||||
self.assertIn('"$oci_count" -eq ${#LXC_AFFECTED_CTIDS[@]}', source, script)
|
||||
|
||||
|
||||
class DiskAndSharePickersHideOci(TestCase):
|
||||
"""Adding a disk or a mount, and the NFS and Samba set-ups, change the
|
||||
container they are given. A container from an OCI image is not offered."""
|
||||
|
||||
def test_every_picker_filters_before_it_lists(self):
|
||||
pickers = ((function('scripts/global/share-common.func', 'select_lxc_container'), 'options+=('),
|
||||
(function('scripts/global/share-common.func', 'select_privileged_lxc'), 'ct_list+='),
|
||||
(function('scripts/share/lxc-mount-manager_minimal.sh', 'select_lxc_container'), 'options+=('))
|
||||
for body, listing in pickers:
|
||||
self.assertLess(body.index('pmx_lxc_is_oci "$id"'), body.index(listing))
|
||||
self.assertIn('Containers created from an OCI image are not listed', body)
|
||||
disk = (ROOT / 'scripts/storage/disk-passthrough_ct.sh').read_text()
|
||||
picker = disk[disk.index('CT_LIST=""'):disk.index('if [ -z "$CTID" ]')]
|
||||
self.assertLess(picker.index('pmx_lxc_is_oci "$ct_id"'), picker.index('CT_LIST+='))
|
||||
self.assertIn('${OCI_NOTE}" $UI_MENU_H $UI_MENU_W $CT_LIST_H', picker)
|
||||
|
||||
def test_a_disk_in_use_is_still_looked_for_in_every_container(self):
|
||||
for script in ('scripts/storage/disk-passthrough_ct.sh', 'scripts/storage/disk-passthrough.sh'):
|
||||
source = (ROOT / script).read_text()
|
||||
check = source[source.index('disk_referenced_in_config "$CT_CONFIG_RAW" "$DISK"'):]
|
||||
scan = check[:check.index("done < <(pct list | awk 'NR>1 {print $1, $3}')")]
|
||||
self.assertNotIn('pmx_lxc_is_oci', scan, script)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""The passthrough entries of a VM that belong to a GPU are the GPU, the audio
|
||||
at its slot and the devices of its IOMMU group. Another function at the same
|
||||
slot is a device of its own: on an AMD APU the USB controllers share the slot
|
||||
of the integrated graphics, and a VM that only has one of them does not use
|
||||
the GPU."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
HELPERS = ROOT / "scripts/global/pci_passthrough_helpers.sh"
|
||||
SCRIPTS = [ROOT / "scripts/gpu_tpu" / name
|
||||
for name in ("switch_gpu_mode.sh", "switch_gpu_mode_direct.sh", "add_gpu_vm.sh")]
|
||||
IGPU = "0000:05:00.0"
|
||||
# The functions of the slot of an AMD APU, and a discrete card.
|
||||
CLASSES = {"0000:05:00.0": "0x030000", "0000:05:00.1": "0x040300", "0000:05:00.3": "0x0c0330",
|
||||
"0000:05:00.4": "0x0c0330", "0000:05:00.6": "0x040300",
|
||||
"0000:01:00.0": "0x030000", "0000:01:00.1": "0x040300"}
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class GpuEntriesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.folder = Path(folder.name)
|
||||
for bdf, value in CLASSES.items():
|
||||
(self.folder / "sys" / bdf).mkdir(parents=True)
|
||||
(self.folder / "sys" / bdf / "class").write_text(value + "\n")
|
||||
|
||||
def entries(self, config, *group, gpu=IGPU):
|
||||
conf = self.folder / "vm.conf"
|
||||
conf.write_text(config)
|
||||
script = f'source "{HELPERS}"; _vm_gpu_hostpci_indexes "$1" "$2" "${{@:3}}"'
|
||||
done = subprocess.run(["bash", "-c", script, "bash", str(conf), gpu, *group], capture_output=True, text=True,
|
||||
env={"PMX_PCI_SYSFS": str(self.folder / "sys"), "PATH": "/usr/bin:/bin"}, check=False)
|
||||
self.assertEqual(done.stderr, "")
|
||||
return done.stdout.split()
|
||||
|
||||
def test_a_vm_with_only_a_usb_controller_of_that_slot_does_not_have_the_gpu(self):
|
||||
self.assertEqual(self.entries("hostpci0: 0000:01:00.0,pcie=1,x-vga=1\nhostpci1: 0000:01:00.1,pcie=1\n"
|
||||
"hostpci2: 0000:05:00.4,pcie=1\n"), [])
|
||||
|
||||
def test_the_gpu_goes_with_the_audio_of_its_slot_and_the_usb_stays(self):
|
||||
self.assertEqual(self.entries("hostpci0: 0000:05:00.0,pcie=1,x-vga=1\nhostpci1: 0000:05:00.1,pcie=1\n"
|
||||
"hostpci2: 0000:05:00.4,pcie=1\nhostpci3: 0000:05:00.6\n"), ["0", "1", "3"])
|
||||
|
||||
def test_a_device_of_the_same_iommu_group_goes_with_the_gpu(self):
|
||||
self.assertEqual(self.entries("hostpci0: 0000:05:00.0\nhostpci1: 0000:05:00.4\n", "0000:05:00.4"), ["0", "1"])
|
||||
|
||||
def test_the_whole_slot_carries_the_gpu(self):
|
||||
self.assertEqual(self.entries("hostpci0: 05:00,pcie=1\nhostpci1: 0000:01:00.0\n"), ["0"])
|
||||
|
||||
def test_several_devices_in_one_entry(self):
|
||||
self.assertEqual(self.entries("hostpci4: 0000:05:00.0;0000:05:00.1,pcie=1\n"), ["4"])
|
||||
|
||||
def test_addresses_are_read_with_or_without_domain_and_in_any_case(self):
|
||||
self.assertEqual(self.entries("hostpci0: 05:00.0,pcie=1\n", gpu="05:00.0"), ["0"])
|
||||
self.assertEqual(self.entries("hostpci0: 0000:0A:00.0\n", gpu="0000:0a:00.0"), ["0"])
|
||||
|
||||
def test_another_gpu_and_a_slot_that_only_looks_alike_are_left(self):
|
||||
# 00:02 inside 0000:02:00.0 is not the same device.
|
||||
self.assertEqual(self.entries("hostpci0: 0000:02:00.0\n", gpu="0000:00:02.0"), [])
|
||||
self.assertEqual(self.entries("hostpci0: 0000:01:00.0\nhostpci1: 0000:05:00.0\n", gpu="0000:01:00.0"), ["0"])
|
||||
|
||||
def test_snapshots_and_resource_mappings_are_not_read(self):
|
||||
self.assertEqual(self.entries("hostpci0: 0000:01:00.0\n[snap1]\nhostpci0: 0000:05:00.0\n"), [])
|
||||
self.assertEqual(self.entries("hostpci0: mapping=gpu1,pcie=1\nhostpci1: 0000:05:00.1\n"), [])
|
||||
|
||||
def test_the_gpu_scripts_decide_through_it(self):
|
||||
for script in SCRIPTS:
|
||||
with self.subTest(script=script.name):
|
||||
source = script.read_text()
|
||||
self.assertIn("_vm_gpu_hostpci_indexes", source)
|
||||
# No entry is deleted, or taken for the GPU, by its slot alone.
|
||||
self.assertNotIn("sed -E -i \"/^hostpci", source)
|
||||
self.assertNotRegex(source, r'grep -qE "hostpci\[0-9\]\+:\.\*\$\{pci_slot\}"')
|
||||
self.assertNotRegex(source, r"\$\{slot\}\(\\\\\.\[0-7\]\)\?")
|
||||
|
||||
def test_the_device_check_does_not_match_a_neighbour(self):
|
||||
script = (f'qm() {{ printf "hostpci0: 0000:05:00.4,pcie=1\\n"; }}; source "{HELPERS}"; '
|
||||
'_pci_slot_assigned_to_vm 0000:05:00.0 100 && echo gpu; '
|
||||
'_pci_slot_assigned_to_vm 0000:05:00.4 100 && echo usb; true')
|
||||
done = subprocess.run(["bash", "-c", script], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(done.stdout.split(), ["usb"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""An addon of the Helper-Scripts runs where its catalog says it belongs: on
|
||||
the Proxmox host, inside a container, or wherever the user chooses when it
|
||||
can be both. Until this, every addon ran on the host."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / "scripts/menus/menu_Helper_Scripts.sh"
|
||||
PLAIN = ROOT / "scripts/menus/helper_plain_text.py"
|
||||
|
||||
# Only the functions under test are loaded, not the menu or its catalog.
|
||||
PRELUDE = textwrap.dedent(f"""
|
||||
set -uo pipefail
|
||||
eval "$(sed -n '/^download_script() {{/,/^RETURN_TO_MAIN=false/p' '{MENU}' | sed '$d')"
|
||||
translate() {{ printf '%s' "$1"; }}
|
||||
clear() {{ :; }}
|
||||
dialog() {{ :; }}
|
||||
pmx_lxc_is_oci() {{ [[ "$1" == 100 ]]; }}
|
||||
MENU_HEIGHT=22; MENU_ROWS=14
|
||||
""")
|
||||
|
||||
|
||||
def bash(script):
|
||||
return subprocess.run(["bash", "-c", PRELUDE + textwrap.dedent(script)], capture_output=True, text=True)
|
||||
|
||||
|
||||
class TargetChoiceTests(unittest.TestCase):
|
||||
def choice(self, kind, places):
|
||||
return bash(f'helper_target_choice {kind} "{places}"').stdout.strip()
|
||||
|
||||
def test_an_addon_for_the_host_runs_on_the_host(self):
|
||||
self.assertEqual(self.choice("addon", "pve"), "host")
|
||||
|
||||
def test_an_addon_for_containers_runs_in_one(self):
|
||||
self.assertEqual(self.choice("addon", "lxc vm"), "lxc")
|
||||
self.assertEqual(self.choice("addon", "lxc"), "lxc")
|
||||
|
||||
def test_an_addon_for_both_asks_where(self):
|
||||
self.assertEqual(self.choice("addon", "pve vm lxc"), "ask")
|
||||
self.assertEqual(self.choice("addon", ""), "ask")
|
||||
|
||||
def test_what_is_not_an_addon_runs_on_the_host(self):
|
||||
# A script that creates a container or a VM belongs on the host,
|
||||
# whatever the catalog lists for the application it installs.
|
||||
self.assertEqual(self.choice("lxc", "lxc"), "host")
|
||||
self.assertEqual(self.choice("vm", "vm"), "host")
|
||||
self.assertEqual(self.choice("pve", "pve"), "host")
|
||||
|
||||
|
||||
class ContainerListTests(unittest.TestCase):
|
||||
def test_oci_containers_are_left_out_and_a_lock_is_shown(self):
|
||||
result = bash(r"""
|
||||
pct() { printf 'VMID Status Lock Name\n100 running chromium\n109 running ordinary\n110 stopped stopped-one\n112 stopped backup locked-one\n'; }
|
||||
helper_lxc_rows
|
||||
""")
|
||||
self.assertEqual(result.stdout,
|
||||
"109\tordinary · running\n110\tstopped-one · stopped\n112\tlocked-one · stopped · backup\n")
|
||||
|
||||
|
||||
class RunTests(unittest.TestCase):
|
||||
def test_the_script_runs_on_the_host_or_inside_the_container_chosen(self):
|
||||
result = bash(r"""
|
||||
curl() { printf 'printf on-host'; }
|
||||
pct() { [[ "$1" == exec && "$2" == 109 && "$3" == --keep-env && "$4" == 0 && "$5" == -- ]] && printf 'in-lxc:%s' "${*: -1}"; }
|
||||
HELPER_LXC_TARGET=''; download_script https://example.invalid/a; echo
|
||||
HELPER_LXC_TARGET=109; download_script https://example.invalid/a; echo
|
||||
""")
|
||||
self.assertEqual(result.stdout, "on-host\nin-lxc:printf on-host\n")
|
||||
|
||||
def test_a_failed_or_empty_download_runs_nothing(self):
|
||||
result = bash(r"""
|
||||
bash() { echo RAN; }
|
||||
pct() { echo RAN; }
|
||||
HELPER_LXC_TARGET=109
|
||||
curl() { return 22; }; download_script https://example.invalid/a && echo UNEXPECTED
|
||||
curl() { printf ' \n'; }; download_script https://example.invalid/a && echo UNEXPECTED
|
||||
echo done
|
||||
""")
|
||||
self.assertEqual(result.stdout, "done\n")
|
||||
|
||||
def test_cancelling_the_choice_leaves_no_target(self):
|
||||
result = bash(r"""
|
||||
dialog() { return 1; }
|
||||
select_helper_target Test ask && echo UNEXPECTED
|
||||
printf '[%s]\n' "$HELPER_LXC_TARGET"
|
||||
""")
|
||||
self.assertEqual(result.stdout, "[]\n")
|
||||
|
||||
def test_the_host_needs_no_question(self):
|
||||
result = bash(r"""
|
||||
dialog() { echo ASKED; return 1; }
|
||||
select_helper_target Test host && printf 'ok[%s]\n' "$HELPER_LXC_TARGET"
|
||||
""")
|
||||
self.assertEqual(result.stdout, "ok[]\n")
|
||||
|
||||
|
||||
class PlainTextTests(unittest.TestCase):
|
||||
def test_html_becomes_text_and_commands_are_kept(self):
|
||||
result = subprocess.run(["python3", str(PLAIN)], capture_output=True, text=True,
|
||||
input="<p>Run <code>ls -la && pwd</code></p><ul><li>one</li><li>two</li></ul>")
|
||||
self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""The shared install functions are sourced by both post-install flows. An
|
||||
update that runs functions of the two sources the file twice in one shell,
|
||||
which has to be as quiet as the first time."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SHARED = ROOT / "scripts/global/utils-install-functions.sh"
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class SourcedTwiceTests(unittest.TestCase):
|
||||
def source(self, times):
|
||||
script = "; ".join([f'source "{SHARED}"'] * times) + '; echo "${PMX_NIC_LINK_DIR}|${#PMX_DKMS_COMPONENTS[@]}"'
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, check=False)
|
||||
|
||||
def test_a_second_source_prints_nothing(self):
|
||||
once, twice = self.source(1), self.source(2)
|
||||
self.assertEqual(twice.stderr, once.stderr)
|
||||
self.assertNotIn("readonly variable", twice.stderr)
|
||||
self.assertEqual(twice.returncode, 0)
|
||||
|
||||
def test_the_values_are_the_same_and_stay_read_only(self):
|
||||
self.assertEqual(self.source(2).stdout.strip(), "/etc/systemd/network|2")
|
||||
attempt = subprocess.run(["bash", "-c", f'source "{SHARED}"; PMX_NIC_LINK_DIR=/tmp'],
|
||||
capture_output=True, text=True, check=False)
|
||||
self.assertIn("readonly variable", attempt.stderr)
|
||||
|
||||
def test_no_other_shared_file_declares_a_read_only_variable_unguarded(self):
|
||||
for path in sorted((ROOT / "scripts/global").glob("*.sh")) + [ROOT / "scripts/utils.sh"]:
|
||||
for line in path.read_text(errors="replace").splitlines():
|
||||
self.assertFalse(line.startswith(("readonly ", "declare -r ")), f"{path.name}: {line[:60]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""A Proxmox host without a subscription is refused by the enterprise
|
||||
repositories on every apt refresh. The installers tell that apart from a
|
||||
refresh that really failed."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
INSTALLERS = ('install_proxmenux.sh', 'install_proxmenux_beta.sh')
|
||||
|
||||
ENTERPRISE = """Hit:1 http://deb.debian.org/debian trixie InRelease
|
||||
Err:2 https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease
|
||||
401 Unauthorized [IP: 51.91.38.34 443]
|
||||
Err:4 https://enterprise.proxmox.com/debian/pve trixie InRelease
|
||||
401 Unauthorized [IP: 51.91.38.34 443]
|
||||
Reading package lists...
|
||||
E: Failed to fetch https://enterprise.proxmox.com/debian/ceph-squid/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
|
||||
E: The repository 'https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease' is not signed.
|
||||
E: Failed to fetch https://enterprise.proxmox.com/debian/pve/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
|
||||
E: The repository 'https://enterprise.proxmox.com/debian/pve trixie InRelease' is not signed.
|
||||
"""
|
||||
NETWORK = """Err:1 http://deb.debian.org/debian trixie InRelease
|
||||
Temporary failure resolving 'deb.debian.org'
|
||||
E: Failed to fetch http://deb.debian.org/debian/dists/trixie/InRelease Temporary failure resolving 'deb.debian.org'
|
||||
"""
|
||||
LOCKED = "E: Could not get lock /var/lib/apt/lists/lock. It is held by process 4321 (apt-get)\n"
|
||||
# apt reports a repository it cannot reach as a warning, not as an error.
|
||||
UNREACHABLE = """Err:6 http://repo.invalid/debian trixie InRelease
|
||||
Could not resolve 'repo.invalid'
|
||||
W: Failed to fetch http://repo.invalid/debian/dists/trixie/InRelease Could not resolve 'repo.invalid'
|
||||
W: Some index files failed to download. They have been ignored, or old ones used instead.
|
||||
"""
|
||||
|
||||
|
||||
def function(installer, name):
|
||||
source = (ROOT / installer).read_text()
|
||||
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
|
||||
|
||||
|
||||
class InstallerAptRefresh(TestCase):
|
||||
def refused(self, installer, log):
|
||||
with tempfile.NamedTemporaryFile('w', suffix='.log') as handle:
|
||||
handle.write(log)
|
||||
handle.flush()
|
||||
script = function(installer, 'apt_only_enterprise_refused') + \
|
||||
f'APT_ERROR_LOG={handle.name if log is not None else ""}\napt_only_enterprise_refused\n'
|
||||
return subprocess.run(['bash', '-c', script], capture_output=True).returncode == 0
|
||||
|
||||
def test_a_refresh_refused_only_by_the_enterprise_repositories_is_good(self):
|
||||
for installer in INSTALLERS:
|
||||
self.assertTrue(self.refused(installer, ENTERPRISE), installer)
|
||||
|
||||
def test_any_other_failure_is_still_a_failure(self):
|
||||
for installer in INSTALLERS:
|
||||
self.assertFalse(self.refused(installer, NETWORK), installer)
|
||||
self.assertFalse(self.refused(installer, LOCKED), installer)
|
||||
self.assertFalse(self.refused(installer, ENTERPRISE + NETWORK), installer)
|
||||
self.assertFalse(self.refused(installer, ENTERPRISE + UNREACHABLE), installer)
|
||||
self.assertFalse(self.refused(installer, 'Reading package lists...\n'), installer)
|
||||
|
||||
def test_the_refresh_step_asks_before_it_warns(self):
|
||||
for installer in INSTALLERS:
|
||||
source = (ROOT / installer).read_text()
|
||||
step = source[source.index('if run_apt update -y; then'):]
|
||||
step = step[:step.index('\n fi\n')]
|
||||
self.assertLess(step.index('elif apt_only_enterprise_refused; then'), step.index('apt cache refresh failed'))
|
||||
self.assertIn('discard_apt_error', step[step.index('elif'):step.index('else\n')])
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Every shipped terminal language must be selectable during installation."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
ROOT = SCRIPTS.parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
from build_translation_cache import DEFAULT_LANGUAGES
|
||||
|
||||
|
||||
class InstallerLanguageTests(unittest.TestCase):
|
||||
def test_both_installers_offer_every_shipped_language(self):
|
||||
expected = {'en', *DEFAULT_LANGUAGES}
|
||||
for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
with self.subTest(installer=name):
|
||||
source = (ROOT / name).read_text(encoding='utf-8')
|
||||
function = re.search(r'^select_language\(\) \{(.*?)^\}', source, re.M | re.S)
|
||||
self.assertIsNotNone(function, 'Missing installer language selector')
|
||||
offered = re.findall(r'^\s*"([a-z]{2})"\s+"[^"]+"', function.group(1), re.M)
|
||||
self.assertEqual(set(offered), expected)
|
||||
self.assertEqual(len(offered), len(expected), 'Duplicate language option')
|
||||
# English is the source text in the scripts, not a JSON catalog.
|
||||
for language in set(offered) - {'en'}:
|
||||
self.assertTrue((ROOT / 'lang' / f'{language}.json').is_file(), language)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Log2RAM keeps /var/log in RAM while the journal and the task logs of
|
||||
Proxmox VE and Proxmox Backup Server stay on disk behind a link. An update keeps the size
|
||||
and interval the host already has."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
KEEP = ROOT / "scripts/global/log2ram_keep_on_disk.sh"
|
||||
SETUP = ROOT / "scripts/global/log2ram_setup.sh"
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class KeepOnDiskTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.root = Path(folder.name)
|
||||
self.log = self.root / "var/log"
|
||||
(self.log / "journal/abc").mkdir(parents=True)
|
||||
(self.log / "journal/abc/system.journal").write_text("entries")
|
||||
(self.log / "pveproxy").mkdir()
|
||||
(self.log / "pveproxy/access.log").write_text("GET /")
|
||||
|
||||
def run_script(self, revert=False):
|
||||
env = dict(os.environ, PMX_L2R_ROOT=str(self.root), PMX_L2R_REVERT="1" if revert else "0")
|
||||
return subprocess.run(["bash", str(KEEP)], env=env, capture_output=True, text=True)
|
||||
|
||||
def test_the_journal_moves_to_disk_behind_a_link(self):
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
journal = self.log / "journal"
|
||||
self.assertTrue(journal.is_symlink())
|
||||
self.assertEqual(os.readlink(journal), "/var/hdd.journal")
|
||||
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||
self.assertTrue((self.log / "pveproxy/access.log").exists())
|
||||
self.assertFalse((self.root / "var/hdd.proxmox-backup").exists())
|
||||
|
||||
def test_running_again_changes_nothing(self):
|
||||
self.run_script()
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
|
||||
|
||||
def test_the_task_logs_of_backup_server_stay_on_disk_too(self):
|
||||
(self.log / "proxmox-backup/tasks").mkdir(parents=True)
|
||||
(self.log / "proxmox-backup/tasks/active").write_text("task")
|
||||
self.run_script()
|
||||
self.assertEqual(os.readlink(self.log / "proxmox-backup"), "/var/hdd.proxmox-backup")
|
||||
self.assertEqual((self.root / "var/hdd.proxmox-backup/tasks/active").read_text(), "task")
|
||||
|
||||
def test_files_written_back_to_the_folder_join_the_copy_on_disk(self):
|
||||
self.run_script()
|
||||
(self.log / "journal").unlink()
|
||||
(self.log / "journal/def").mkdir(parents=True)
|
||||
(self.log / "journal/def/system.journal").write_text("later")
|
||||
self.run_script()
|
||||
disk = self.root / "var/hdd.journal"
|
||||
self.assertEqual(sorted(p.name for p in disk.iterdir()), ["abc", "def"])
|
||||
self.assertTrue((self.log / "journal").is_symlink())
|
||||
|
||||
def test_a_host_without_a_journal_folder_gets_one_on_disk(self):
|
||||
shutil.rmtree(self.log / "journal")
|
||||
self.run_script()
|
||||
self.assertTrue((self.root / "var/hdd.journal").is_dir())
|
||||
self.assertTrue((self.log / "journal").is_symlink())
|
||||
|
||||
def test_the_task_logs_of_proxmox_ve_stay_on_disk(self):
|
||||
(self.log / "pve/tasks").mkdir(parents=True)
|
||||
(self.log / "pve/tasks/index").write_text("UPID")
|
||||
self.run_script()
|
||||
self.assertEqual(os.readlink(self.log / "pve"), "/var/hdd.pve")
|
||||
self.assertEqual((self.root / "var/hdd.pve/tasks/index").read_text(), "UPID")
|
||||
|
||||
def test_removing_log2ram_puts_the_folders_back(self):
|
||||
(self.log / "proxmox-backup").mkdir()
|
||||
self.run_script()
|
||||
self.assertEqual(self.run_script(revert=True).returncode, 0)
|
||||
for name in ("journal", "proxmox-backup"):
|
||||
self.assertFalse((self.log / name).is_symlink(), name)
|
||||
self.assertTrue((self.log / name).is_dir(), name)
|
||||
self.assertFalse((self.root / f"var/hdd.{name}").exists(), name)
|
||||
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class KeptSettingsTests(unittest.TestCase):
|
||||
def read(self, function, files):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script = SETUP.read_text()
|
||||
for path, content in files.items():
|
||||
target = Path(folder) / path.lstrip("/")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content)
|
||||
script = script.replace(path, str(target))
|
||||
library = Path(folder) / "setup.sh"
|
||||
library.write_text(script)
|
||||
result = subprocess.run(["bash", "-c", f'source "{library}"; {function}'],
|
||||
capture_output=True, text=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
def test_the_size_comes_from_the_installed_configuration(self):
|
||||
self.assertEqual(self.read("pmx_log2ram_current_size", {"/etc/log2ram.conf": "SIZE=256M\n"}), "256")
|
||||
|
||||
def test_the_interval_comes_from_the_timer_of_this_version(self):
|
||||
timer = "[Timer]\nOnCalendar=\nOnCalendar=*-*-* 00/6:55:00\n"
|
||||
self.assertEqual(self.read("pmx_log2ram_current_hours",
|
||||
{"/etc/systemd/system/log2ram-daily.timer.d/proxmenux.conf": timer}), "6")
|
||||
|
||||
def test_the_interval_of_an_earlier_version_is_kept(self):
|
||||
cron = "MAILTO=\"\"\n0 */3 * * * root /usr/local/bin/log2ram write >/dev/null 2>&1\n"
|
||||
self.assertEqual(self.read("pmx_log2ram_current_hours", {"/etc/cron.d/log2ram": cron}), "3")
|
||||
|
||||
def test_the_release_is_pinned_to_a_verified_commit(self):
|
||||
text = SETUP.read_text()
|
||||
self.assertIn('PMX_L2R_TAG="1.7.2"', text)
|
||||
self.assertIn('PMX_L2R_COMMIT="f171449530f8984374c8f69fff3c92af813a694b"', text)
|
||||
self.assertNotIn("install.sh", text.split("_pmx_log2ram_install_release() {", 1)[1].split("\n}\n", 1)[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Inert branch tests for the real Lynis deletion/uninstall producers."""
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
def extracted(path, name, namespace):
|
||||
tree = ast.parse((ROOT / path).read_text())
|
||||
fn = next(x for x in tree.body if isinstance(x, ast.FunctionDef) and x.name == name)
|
||||
fn.decorator_list = []
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[fn], type_ignores=[])), str(path), 'exec'), namespace)
|
||||
return namespace[name]
|
||||
|
||||
class LynisFeedbackProducer(unittest.TestCase):
|
||||
def test_actual_ui_feedback_handlers_all_shipped_locales(self):
|
||||
result = subprocess.run(['node', str(ROOT / '.github/scripts/tests/fixtures/lynis_feedback_contract.cjs')],
|
||||
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_uninstall_nothing_to_remove_is_not_reported_as_uninstalled(self):
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: False, join=lambda *p: '/'.join(p)), remove=lambda p: self.fail(p))
|
||||
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
|
||||
self.assertFalse(result[0])
|
||||
self.assertEqual(result[3], 'no_files')
|
||||
manager = SimpleNamespace(uninstall_lynis=lambda: result)
|
||||
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
|
||||
self.assertEqual(route()['outcome'], 'no_files')
|
||||
|
||||
def test_report_outcomes(self):
|
||||
paths = ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
|
||||
for fail_at in (None, 0, 1, 'missing'):
|
||||
with self.subTest(fail_at=fail_at):
|
||||
removed = []
|
||||
def remove(p):
|
||||
if fail_at in (0, 1) and p == paths[fail_at]:
|
||||
raise PermissionError('inert denial')
|
||||
removed.append(p)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: fail_at != 'missing'), remove=remove)
|
||||
ns = {'security_manager': object(), 'jsonify': lambda data: data}
|
||||
orig_import = __import__
|
||||
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_os if n == 'os' else orig_import(n, *a, **kw)):
|
||||
result = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_report_delete', ns)()
|
||||
body, status = result if isinstance(result, tuple) else (result, 200)
|
||||
self.assertEqual(removed, paths[:fail_at] if fail_at in (0, 1) else ([] if fail_at == 'missing' else paths))
|
||||
self.assertEqual(body['success'], fail_at is None)
|
||||
if fail_at == 'missing':
|
||||
self.assertEqual(body['outcome'], 'no_files')
|
||||
elif fail_at in (0, 1):
|
||||
self.assertEqual(status, 500)
|
||||
self.assertEqual(body['partial'], fail_at == 1)
|
||||
self.assertIn('inert denial', body['message'])
|
||||
|
||||
def test_uninstall_outcomes_and_route(self):
|
||||
targets = ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
|
||||
for fail_at in (None, 0, 2):
|
||||
with self.subTest(fail_at=fail_at):
|
||||
removed = []
|
||||
def remove(path):
|
||||
if fail_at is not None and path == targets[fail_at]: raise PermissionError('inert denial')
|
||||
removed.append(path)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p in targets, join=lambda *parts: '/'.join(parts)), remove=remove)
|
||||
fake_shutil = SimpleNamespace(rmtree=remove)
|
||||
orig_import = __import__
|
||||
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_shutil if n == 'shutil' else orig_import(n, *a, **kw)):
|
||||
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
|
||||
self.assertEqual(removed, targets[:fail_at] if fail_at is not None else targets)
|
||||
self.assertEqual(result[0], fail_at is None)
|
||||
self.assertEqual(result[2], fail_at == 2)
|
||||
manager = SimpleNamespace(uninstall_lynis=lambda: result)
|
||||
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
|
||||
self.assertEqual(route()['partial'], fail_at == 2)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Inert source-contract checks; never import operational modules or touch host paths."""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
APP = ROOT / 'AppImage'
|
||||
|
||||
|
||||
def extract_function(path, name, *, owner=None):
|
||||
tree = ast.parse(path.read_text())
|
||||
container = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner) if owner else tree
|
||||
node = next(n for n in container.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
namespace = {}
|
||||
return node, ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[]))
|
||||
|
||||
|
||||
def leaf(pointer):
|
||||
data = json.loads((APP / 'messages/en/common.json').read_text())
|
||||
for segment in pointer.split('.'):
|
||||
data = data[segment]
|
||||
return data
|
||||
|
||||
|
||||
class LynisThresholdContracts(unittest.TestCase):
|
||||
def test_delete_confirmation_names_all_three_actual_targets(self):
|
||||
node, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
removed = []
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=removed.append)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == 'os':
|
||||
return fake_os
|
||||
return original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, str(APP / 'scripts/flask_security_routes.py'), 'exec'), namespace)
|
||||
result = namespace['lynis_report_delete']()
|
||||
self.assertEqual(result['success'], True)
|
||||
self.assertEqual(removed, ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
|
||||
text = leaf('securityPage.confirm.deleteAuditReport').lower()
|
||||
self.assertIn('report', text)
|
||||
self.assertIn('logs', text)
|
||||
self.assertNotIn('this audit report?', text)
|
||||
|
||||
def test_delete_route_partial_failure_is_not_reported_as_full_success(self):
|
||||
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
removed = []
|
||||
def remove(path):
|
||||
if path.endswith('lynis.log'):
|
||||
raise PermissionError('inert denial')
|
||||
removed.append(path)
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=remove)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
|
||||
result, status = namespace['lynis_report_delete']()
|
||||
self.assertEqual(removed, ['/var/log/lynis-report.dat'])
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('inert denial', result['message'])
|
||||
|
||||
def test_delete_route_with_no_files_returns_success_false_not_a_clean_result(self):
|
||||
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: False), remove=lambda p: self.fail(p))
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
|
||||
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
|
||||
result = namespace['lynis_report_delete']()
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('No report files', result['message'])
|
||||
|
||||
def test_uninstall_notice_describes_existing_cleanup_targets(self):
|
||||
_, module = extract_function(APP / 'scripts/security_manager.py', 'uninstall_lynis')
|
||||
removed = []
|
||||
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p != '/usr/local/share/proxmenux/components_status.json', join=lambda *s: '/'.join(s)), remove=removed.append)
|
||||
class FakeShutil:
|
||||
@staticmethod
|
||||
def rmtree(path):
|
||||
removed.append(path)
|
||||
original_import = __import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
return FakeShutil if name == 'shutil' else original_import(name, *args, **kwargs)
|
||||
namespace = {'os': fake_os}
|
||||
with patch('builtins.__import__', side_effect=fake_import):
|
||||
exec(compile(module, '<inert lynis uninstall>', 'exec'), namespace)
|
||||
result = namespace['uninstall_lynis']()
|
||||
self.assertTrue(result[0])
|
||||
self.assertEqual(removed, ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
|
||||
for key in ('securityPage.lynis.removeReports', 'securityPage.lynis.uninstallConfirmDescription'):
|
||||
text = leaf(key).lower()
|
||||
self.assertIn('report', text)
|
||||
self.assertIn('logs', text)
|
||||
self.assertNotIn('monitor-created', text)
|
||||
|
||||
def test_swap_hint_qualifies_sampling_and_independent_ram_alarm(self):
|
||||
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
|
||||
# The method is bound to an inert class, not the production constructor.
|
||||
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
|
||||
MEMORY_WARNING=80, MEMORY_CRITICAL=90, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
|
||||
memory = SimpleNamespace(percent=50, available=10, total=100)
|
||||
swap = SimpleNamespace(percent=90, used=90, total=100)
|
||||
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
|
||||
'time': SimpleNamespace(time=lambda: 1000)}
|
||||
exec(compile(module, '<inert memory check>', 'exec'), namespace)
|
||||
first = namespace['_check_memory_comprehensive'](self_obj)
|
||||
second = namespace['_check_memory_comprehensive'](self_obj)
|
||||
self.assertEqual(first['status'], 'OK')
|
||||
self.assertEqual(second['checks']['swap_usage']['status'], 'CRITICAL')
|
||||
self_obj.state_history.clear()
|
||||
memory.percent = 91
|
||||
swap.percent = 0
|
||||
for _ in range(8):
|
||||
last = namespace['_check_memory_comprehensive'](self_obj)
|
||||
self.assertEqual(last['status'], 'CRITICAL')
|
||||
self.assertEqual(last['checks']['swap_usage']['status'], 'OK')
|
||||
hint = leaf('settings.healthThresholds.swapPressureHint').lower()
|
||||
self.assertIn('swap usage', hint)
|
||||
self.assertIn('repeated', hint)
|
||||
self.assertIn('ram', hint)
|
||||
self.assertNotIn('swap file', hint)
|
||||
self.assertNotIn('swap file', leaf('settings.healthThresholds.swapHighLabel').lower())
|
||||
self.assertNotIn('only when both', hint)
|
||||
|
||||
def test_print_generator_and_descriptions(self):
|
||||
node = ROOT / '.github/scripts/tests/fixtures/lynis_print_contract.cjs'
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
result = subprocess.run(['node', str(node)], cwd=ROOT, text=True, capture_output=True,
|
||||
env={**os.environ, 'LYNIS_FIXTURE_LANG': lang})
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn(f'print HTML ({lang}):', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,209 @@
|
||||
"""The search of the main menu finds an option of ProxMenux, an OCI application
|
||||
or a Helper-Script by its name or by related words, and opens it where it
|
||||
lives: the scripts it launches are the ones the menus launch."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
|
||||
|
||||
def load():
|
||||
spec = importlib.util.spec_from_file_location("search_menu", SCRIPTS / "menus/search_menu.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
# The repository in the place of the installed copy.
|
||||
module.BASE, module.SCRIPTS = ROOT, SCRIPTS
|
||||
module.OCI_INDEX = ROOT / "oci/catalog/index.json"
|
||||
module.HELPERS_CACHE = ROOT / "json/helpers_cache.json"
|
||||
module._translations = {}
|
||||
return module
|
||||
|
||||
|
||||
class IndexTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.index = json.loads((SCRIPTS / "menus/search_index.json").read_text(encoding="utf-8"))
|
||||
cls.spanish = json.loads((ROOT / "lang/es.json").read_text(encoding="utf-8"))
|
||||
|
||||
def test_every_entry_opens_a_script_that_exists(self):
|
||||
missing = sorted({item["script"] for item in self.index if not (SCRIPTS / item["script"]).is_file()})
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_every_title_is_a_text_the_menus_already_translate(self):
|
||||
names = {"ZimaOS", "Synology DSM"}
|
||||
unknown = [item["title"] for item in self.index
|
||||
if item["title"] not in self.spanish and item["title"] not in names]
|
||||
self.assertEqual(unknown, [])
|
||||
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
|
||||
|
||||
def test_every_script_a_pve_menu_launches_can_be_found(self):
|
||||
indexed = {item["script"] for item in self.index}
|
||||
launched = set()
|
||||
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
|
||||
# PBS replaces the PVE main menu and does not use its search.
|
||||
# The exclusive platform dispatch is executed in the test below.
|
||||
if menu.name in ("main_menu.sh", "lxc_menu.sh", "pbs_menu.sh"):
|
||||
continue
|
||||
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
|
||||
launched -= {"menus/main_menu.sh"}
|
||||
# Opened from their own menu, which is what the index points at.
|
||||
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
|
||||
self.assertEqual(sorted(launched - indexed), [])
|
||||
|
||||
def test_pbs_replaces_the_searchable_pve_menu(self):
|
||||
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||
dispatch = menu[menu.rindex('source "$LOCAL_SCRIPTS/global/platform-functions.sh"'):]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
scripts = Path(directory)
|
||||
(scripts / "global").mkdir()
|
||||
(scripts / "menus").mkdir()
|
||||
helper = scripts / "global/platform-functions.sh"
|
||||
helper.write_text(
|
||||
"source " + shlex.quote(str(SCRIPTS / "global/platform-functions.sh")) + "\n"
|
||||
'pmx_platform_field() { printf "%s" "$TEST_PROXMENUX_PRODUCT"; }\n')
|
||||
(scripts / "menus/pbs_menu.sh").write_text('printf "PBS menu\\n"\n')
|
||||
harness = 'show_menu() { printf "PVE search menu\\n"; }\n' + dispatch
|
||||
for product, expected in (("pve", "PVE search menu"), ("pbs", "PBS menu")):
|
||||
with self.subTest(product=product):
|
||||
result = subprocess.run(["bash", "-e", "-c", harness], text=True, capture_output=True,
|
||||
env={**os.environ, "LOCAL_SCRIPTS": str(scripts),
|
||||
"TEST_PROXMENUX_PRODUCT": product})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), expected)
|
||||
|
||||
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
|
||||
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||
self.assertIn('search "$(translate "Search options and applications")"', menu)
|
||||
self.assertIn('search_menu.py" --handoff "$handoff"', menu)
|
||||
self.assertIn('[[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]', menu)
|
||||
self.assertIn('exec bash "${target[@]}"', menu)
|
||||
|
||||
def test_an_option_inside_a_menu_names_one_that_menu_has(self):
|
||||
for item in self.index:
|
||||
argument = item.get("argument")
|
||||
if not argument:
|
||||
continue
|
||||
script = (SCRIPTS / item["script"]).read_text()
|
||||
self.assertIn("PRESELECTED_OPTION", script, item["script"])
|
||||
if not argument.isdigit():
|
||||
self.assertIn(argument, script, f"{item['script']}: {argument}")
|
||||
|
||||
|
||||
class CatalogTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.search = load()
|
||||
cls.items = cls.search.catalog(refresh=False)
|
||||
|
||||
def found(self, query):
|
||||
return self.search.search(self.items, query)
|
||||
|
||||
def test_the_three_sources_are_in_the_catalog(self):
|
||||
self.assertEqual({item["source"] for item in self.items}, set(self.search.SOURCES))
|
||||
|
||||
def test_proxmenux_comes_first_then_oci_then_helper_scripts(self):
|
||||
sources = [item["source"] for item in self.found("samba")]
|
||||
self.assertEqual(sources, sorted(sources, key=self.search.SOURCES.index))
|
||||
self.assertEqual(sources[0], "ProxMenux")
|
||||
|
||||
def test_a_name_is_found_by_the_start_of_any_of_its_words(self):
|
||||
self.assertIn("Jellyfin", [item["title"] for item in self.found("jelly")])
|
||||
self.assertTrue(any(item["source"] == "OCI Manager" for item in self.found("plex")))
|
||||
|
||||
def test_accents_and_case_do_not_matter(self):
|
||||
self.assertEqual([item["title"] for item in self.found("NVIDIA")],
|
||||
[item["title"] for item in self.found("nvidia")])
|
||||
|
||||
def test_related_words_find_an_option(self):
|
||||
self.assertTrue(any("NFS" in item["title"] for item in self.found("nfs storage")))
|
||||
self.assertTrue(any(item["action"][-1].endswith("fail2ban_installer.sh") for item in self.found("fail2ban")))
|
||||
|
||||
def test_a_match_by_category_comes_after_the_names(self):
|
||||
matches = self.found("vpn")
|
||||
self.assertTrue(any(item["title"] == "Tailscale" for item in matches))
|
||||
by_category = False
|
||||
for item in matches:
|
||||
words = re.findall(r"[a-z0-9]+", self.search.fold(item["terms"]))
|
||||
in_name = any(word.startswith("vpn") for word in words)
|
||||
self.assertFalse(in_name and by_category)
|
||||
by_category = by_category or not in_name
|
||||
|
||||
def test_an_application_is_not_found_by_what_its_description_mentions(self):
|
||||
titles = [item["title"] for item in self.found("plex")]
|
||||
self.assertNotIn("Jellyfin", titles)
|
||||
self.assertTrue(all("plex" in title.casefold() for title in titles))
|
||||
|
||||
def test_nothing_matches_nothing(self):
|
||||
self.assertEqual(self.found("zzqqxx"), [])
|
||||
self.assertEqual(self.found(" "), [])
|
||||
|
||||
def test_a_sub_menu_is_handed_back_to_the_main_menu(self):
|
||||
by_script = {item["action"][1]: item for item in self.items if item["source"] == "ProxMenux"}
|
||||
self.assertTrue(by_script[str(SCRIPTS / "menus/network_menu.sh")]["handoff"])
|
||||
self.assertFalse(by_script[str(SCRIPTS / "storage/format-disk.sh")]["handoff"])
|
||||
|
||||
def test_an_option_inside_a_menu_is_given_to_that_menu(self):
|
||||
zfs = next(item for item in self.items if item["action"][1:] == [str(SCRIPTS / "help_info_menu.sh"), "7"])
|
||||
self.assertFalse(zfs["handoff"])
|
||||
language = next(item for item in self.items if item["action"][-1] == "change_language")
|
||||
self.assertEqual(language["action"][1], str(SCRIPTS / "menus/config_menu.sh"))
|
||||
self.assertTrue(language["handoff"])
|
||||
|
||||
|
||||
class OpenedInPlaceTests(unittest.TestCase):
|
||||
def test_an_option_of_the_post_install_comes_selected(self):
|
||||
script = (SCRIPTS / "post_install/customizable_post_install.sh").read_text()
|
||||
self.assertIn('PRESELECTED_OPTION="${1:-}"', script)
|
||||
self.assertIn('--default-item "${preselected_item:-1}"', script)
|
||||
|
||||
def test_a_utility_comes_selected(self):
|
||||
script = (SCRIPTS / "utilities/system_utils.sh").read_text()
|
||||
self.assertIn('PRESELECTED_UTILITY="${1:-}"', script)
|
||||
|
||||
def test_a_helper_script_is_opened_on_its_own(self):
|
||||
script = (SCRIPTS / "menus/menu_Helper_Scripts.sh").read_text()
|
||||
self.assertIn('run_script_by_slug "$1"', script)
|
||||
|
||||
def test_an_oci_application_opens_its_page(self):
|
||||
cli = (ROOT / "oci/src/proxmenux_oci/cli.py").read_text()
|
||||
self.assertIn('subparsers.add_parser("open"', cli)
|
||||
|
||||
|
||||
class TranslationTests(unittest.TestCase):
|
||||
"""What the search and the main menu say reaches the translation workflow,
|
||||
read with the folders the workflow itself passes."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
workflow = (ROOT / ".github/workflows/build-translation-cache.yml").read_text()
|
||||
extractor = runpy.run_path(str(ROOT / ".github/scripts/build_translation_cache.py"))
|
||||
folders = [ROOT / folder for folder in re.findall(r"--python-dir (\S+)", workflow)]
|
||||
cls.found = set(extractor["extract_python_texts"](folders))
|
||||
cls.found |= set(extractor["extract_translate_texts"](SCRIPTS))
|
||||
cls.triggers = workflow
|
||||
|
||||
def test_every_text_of_the_search_is_found(self):
|
||||
source = (SCRIPTS / "menus/search_menu.py").read_text()
|
||||
said = set(re.findall(r"translate\('([^']+)'\)", source))
|
||||
self.assertIn("No results", said)
|
||||
self.assertEqual(sorted(said - self.found), [])
|
||||
|
||||
def test_the_title_of_the_main_menu_is_found(self):
|
||||
self.assertIn("Main menu", self.found)
|
||||
|
||||
def test_a_change_in_the_search_runs_the_workflow(self):
|
||||
self.assertIn("'scripts/**/*.py'", self.triggers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Backup-only maintainer contract at actual render/dispatch/email seams.
|
||||
|
||||
Scope-approved seams: receiver, template lookup, rich enrichment, inert queued
|
||||
and manual delivery with an email capture sink. No host operations or sends.
|
||||
"""
|
||||
import unittest
|
||||
from notification_fixture import templates, LANGUAGES
|
||||
from notification_final_fixture import deliver
|
||||
|
||||
|
||||
class BackupSplitTests(unittest.TestCase):
|
||||
def test_recovery_default_is_upstream_resolved_without_proof(self):
|
||||
for language in LANGUAGES:
|
||||
data = {'hostname': 'alias', 'category': 'temperature',
|
||||
'reason': 'Temperature high (recovered)', 'duration': '2m',
|
||||
'original_severity': 'WARNING'}
|
||||
rendered = templates.render_template('error_resolved', data, language)
|
||||
expected = templates.runtime_message('templates.error_resolved.title', language,
|
||||
hostname='alias', category='temperature', entity_suffix='')
|
||||
self.assertEqual(rendered['title'], expected)
|
||||
rich, _ = templates.enrich_with_emojis('error_resolved', rendered['title'], rendered['body'], data)
|
||||
self.assertTrue(rich.startswith('✅ '))
|
||||
result = deliver('error_resolved', data, 'OK', language)
|
||||
self.assertIn('background:#f0fdf4;', result['html'])
|
||||
|
||||
def test_pre_guest_native_subject_keeps_only_cause_once_with_display_alias(self):
|
||||
from notification_fixture import receive
|
||||
raw_host = 'pve-production.internal.example'
|
||||
message = ('Details\n=======\nVMID Name Status Time Size Filename\n'
|
||||
'\nTotal running time: 0s\nTotal size: 0 B')
|
||||
for raw in (message, 'ERROR: unable to activate storage PBS\n' + message):
|
||||
event = receive(raw, 'error', 'vzdump backup status (' + raw_host +
|
||||
'): backup failed: unable to activate storage PBS')
|
||||
event.data['hostname'] = 'display-alias {rack.location}'
|
||||
for language in LANGUAGES:
|
||||
for manual in (False, True):
|
||||
result = deliver(event.event_type, event.data, event.severity, language, manual=manual)
|
||||
self.assertNotIn(raw_host, result['text'])
|
||||
self.assertNotIn('vzdump backup status', result['text'])
|
||||
self.assertEqual(result['text'].count('unable to activate storage PBS'), 1)
|
||||
self.assertIn('display-alias {rack.location}', result['title'])
|
||||
self.assertEqual(result['data']['pve_title'], event.data['pve_title'])
|
||||
self.assertEqual(result['data']['pve_message'], raw)
|
||||
# No global hostname or guest-name substitution: actual diagnostics are
|
||||
# authoritative, even when they happen to contain the original hostname.
|
||||
event = receive(message, 'error', 'vzdump backup status (' + raw_host +
|
||||
'): backup failed: cannot connect to ' + raw_host)
|
||||
result = deliver(event.event_type, {**event.data, 'hostname': 'alias'}, event.severity)
|
||||
self.assertEqual(result['body'].count('cannot connect to ' + raw_host), 1)
|
||||
self.assertNotIn('vzdump backup status', result['body'])
|
||||
|
||||
def test_backup_legacy_keys_keep_meaning_and_unknown_uses_new_keys(self):
|
||||
# Frozen upstream contract; no Git history required in shipped tests.
|
||||
legacy = {'en': {'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})', 'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}', 'label': 'Backup complete'}, 'de': {'title': '{hostname} → {storage}: Sicherung abgeschlossen – {vmname} ({vmid})', 'body': 'Die Sicherung von {vmname} (ID: {vmid}) wurde am {storage} erfolgreich abgeschlossen.\nGröße: {size}', 'label': 'Sicherung abgeschlossen'}, 'es': {'title': '{hostname} → {storage}: Backup completado — {vmname} ({vmid})', 'body': 'El backup de {vmname} (ID: {vmid}) se ha completado correctamente en {storage}.\nTamaño: {size}', 'label': 'Backup completado'}, 'fr': {'title': '{hostname} → {storage}\xa0: Sauvegarde terminée — {vmname} ({vmid})', 'body': "La sauvegarde de {vmname} (ID\xa0: {vmid}) s'est terminée avec succès le {storage}.\nTaille\xa0: {size}", 'label': 'Sauvegarde terminée'}, 'it': {'title': '{hostname} → {storage}: Backup completato — {vmname} ({vmid})', 'body': 'Backup di {vmname} (ID: {vmid}) completato con successo su {storage}.\nTaglia: {size}', 'label': 'Backup completato'}, 'pt': {'title': '{hostname} → {storage}: Backup concluído — {vmname} ({vmid})', 'body': 'Backup de {vmname} (ID: {vmid}) concluído com sucesso em {storage}.\nTamanho: {size}', 'label': 'Backup concluído'}, 'sk': {'title': '{hostname} → {storage}: Záloha dokončená — {vmname} ({vmid})', 'body': 'Záloha {vmname} (ID: {vmid}) na úložisku {storage} bola úspešne dokončená.\nVeľkosť: {size}', 'label': 'Záloha bola dokončená'}, 'sv': {'title': '{hostname} → {storage}: Säkerhetskopiering klar — {vmname} ({vmid})', 'body': 'Säkerhetskopiering av {vmname} (ID: {vmid}) slutfördes framgångsrikt på {storage}.\nStorlek: {size}', 'label': 'Säkerhetskopieringen är klar'}}
|
||||
for language, expected in legacy.items():
|
||||
self.assertEqual(templates._load_runtime_catalog(language)['templates']['backup_complete'], expected)
|
||||
result = templates.render_template('backup_complete', {'hostname': 'alias {rack.location}'}, language)
|
||||
self.assertIn('alias {rack.location}', result['title'])
|
||||
self.assertNotIn('()', result['title'])
|
||||
new_title = templates.runtime_message('backup.unconfirmedTitle', language, hostname='alias {rack.location}')
|
||||
self.assertTrue(new_title)
|
||||
self.assertEqual(result['title'], new_title)
|
||||
self.assertIn(templates.runtime_message('backup.unconfirmedBody', language), result['body'])
|
||||
# Absent/blank/non-string translation uses English per-key fallback.
|
||||
from unittest.mock import patch
|
||||
english = templates._load_runtime_catalog('en')
|
||||
for value in (None, '', {}, []):
|
||||
missing = {'backup': {'unconfirmedTitle': value, 'unconfirmedBody': value}}
|
||||
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else missing):
|
||||
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
|
||||
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
|
||||
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
|
||||
# Both catalogs missing: new outcome text still has explicit EN defaults.
|
||||
with patch.object(templates, '_load_runtime_catalog', return_value={}):
|
||||
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
|
||||
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
|
||||
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
|
||||
|
||||
def test_restore_reports_its_tasks_without_calling_the_node_ready_and_keeps_the_success_icon(self):
|
||||
import json
|
||||
from pathlib import Path
|
||||
from notification_final_fixture import restore_event
|
||||
messages = Path(__file__).resolve().parents[3] / 'AppImage/messages'
|
||||
first = {language: json.loads((messages / language / 'common.json').read_text())
|
||||
['runtime']['notifications']['templates']['system_restore_completed']['body'].split('\n')[0]
|
||||
for language in LANGUAGES}
|
||||
for warning in ('', 'missing module zfs'):
|
||||
event = restore_event(warning)
|
||||
for language in LANGUAGES:
|
||||
result = deliver(event['event_type'], event['data'], event['severity'], language)
|
||||
self.assertTrue(result['title'].startswith('✅ '))
|
||||
self.assertIn(first[language], result['body'])
|
||||
self.assertIn(first[language], result['text'])
|
||||
self.assertNotIn('fully ready', result['body'])
|
||||
self.assertIn('2m', result['text'])
|
||||
if warning:
|
||||
self.assertIn(warning, result['text'])
|
||||
quiet = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
|
||||
self.assertIn('✅', quiet['body'])
|
||||
self.assertIn(first[language], quiet['body'])
|
||||
self.assertIn('white-space:pre-wrap;', quiet['html'])
|
||||
|
||||
def test_spanish_outcome_and_restore_titles_are_capitalized_and_failure_is_exact(self):
|
||||
expected = {'confirmed': 'Backup completado', 'completed_with_warnings': 'Backup completado con advertencias',
|
||||
'unconfirmed': 'Resultado del backup sin confirmar', 'failed': 'Backup fallido'}
|
||||
for outcome, title in expected.items():
|
||||
result = templates.render_template('backup_complete', {'hostname': 'alias', 'backup_outcome': outcome}, 'es')
|
||||
self.assertEqual(result['title'], 'alias: ' + title)
|
||||
failure = templates.render_template('backup_fail', {'hostname': 'alias'}, 'es')
|
||||
self.assertEqual(failure['title'], 'alias: Backup fallido')
|
||||
restore = templates.render_template('system_restore_completed', {'hostname': 'alias'}, 'es')
|
||||
self.assertEqual(restore['title'], 'alias: Restauración del host finalizada')
|
||||
|
||||
def test_recovery_only_quiet_release_keeps_upstream_summary_contract(self):
|
||||
result = deliver('error_resolved', {'hostname': 'alias', 'category': 'temperature',
|
||||
'reason': 'old (recovered)', 'duration': '2m'}, 'OK', quiet=True)
|
||||
self.assertIn(templates.runtime_message('digest.lead', 'en', count=1).strip(), result['body'])
|
||||
self.assertIn(templates.runtime_message('digest.footer', 'en'), result['body'])
|
||||
self.assertIn('✅', result['body'])
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Whole-PR outcome corrections, inert producer/actual email consumers."""
|
||||
import html
|
||||
import unittest
|
||||
from notification_fixture import templates, receive, email, LANGUAGES
|
||||
|
||||
REPORT = """Details
|
||||
=======
|
||||
VMID Name Status Time Size Filename
|
||||
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
|
||||
|
||||
Total running time: 1m 1s
|
||||
Total size: 1 GiB
|
||||
"""
|
||||
|
||||
class CorrectionTests(unittest.TestCase):
|
||||
def test_reversed_finish_is_not_completion_evidence(self):
|
||||
event = receive('INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Starting Backup of VM 100 (qemu)')
|
||||
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
|
||||
|
||||
|
||||
def test_interleaved_complete_logs_keep_both_finished_guests(self):
|
||||
message = ('INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: web\n'
|
||||
'INFO: Starting Backup of VM 101 (lxc)\nINFO: CT Name: db\n'
|
||||
'INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Finished Backup of VM 101 (00:01:02)')
|
||||
event = receive(message)
|
||||
self.assertEqual(event.data['backup_outcome'], 'confirmed')
|
||||
result, markup = email(event.event_type, event.data, event.severity)
|
||||
self.assertIn('✅ VM web (100)', result['body'])
|
||||
self.assertIn('✅ CT db (101)', result['body'])
|
||||
self.assertNotIn('❔', result['body'])
|
||||
self.assertIn('00:01:01', result['body'])
|
||||
|
||||
|
||||
def test_backup_identity_has_event_scoped_mail_compatible_wrapping(self):
|
||||
result, markup = email('backup_complete', {'hostname': 'n' * 64,
|
||||
'backup_outcome': 'confirmed', 'pve_message': 'INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: customerproductionpostgresqlreplicaeuropewestdatacenter01\nINFO: Finished Backup of VM 100 (00:01:01)'})
|
||||
self.assertIn('table-layout:fixed;', markup)
|
||||
title_tag = markup.split('<h2 style="', 1)[1].split('"', 1)[0]
|
||||
self.assertIn('overflow-wrap:break-word;', title_tag)
|
||||
self.assertIn('word-wrap:break-word;', title_tag)
|
||||
_, unrelated = email('node_reconnect', {'hostname': 'node-a'}, 'OK')
|
||||
self.assertNotIn('table-layout:fixed;', unrelated)
|
||||
|
||||
|
||||
def test_catalog_parity_accepts_eventual_valid_slovak_keys(self):
|
||||
from notification_fixture import extract, SCRIPTS
|
||||
import copy
|
||||
import string
|
||||
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
|
||||
ns = {'string': string}
|
||||
ns['_placeholders'] = extract(path, '_placeholders', None, ns)
|
||||
parity = extract(path, 'test_runtime_catalog_keys_and_placeholders_match', 'RuntimeCatalogTests', ns)
|
||||
probe = unittest.TestCase()
|
||||
probe.catalogs = {lang: copy.deepcopy(templates._load_runtime_catalog(lang)) for lang in LANGUAGES}
|
||||
parity(probe) # shipped missing keys remain allowed
|
||||
probe.catalogs['sk']['backup'] = copy.deepcopy(probe.catalogs['en']['backup'])
|
||||
probe.catalogs['sk']['channels']['email']['status']['unconfirmed'] = probe.catalogs['en']['channels']['email']['status']['unconfirmed']
|
||||
parity(probe) # generation of exactly the pending keys is legal
|
||||
probe.catalogs['sk']['backup']['confirmedTitle'] = 'Missing hostname token'
|
||||
with self.assertRaises(AssertionError): parity(probe)
|
||||
|
||||
|
||||
def test_actual_neutral_style_is_not_success_green(self):
|
||||
for event, severity, data in (('backup_complete', 'INFO', {'backup_outcome': 'unconfirmed'}),):
|
||||
result, markup = email(event, data, severity)
|
||||
self.assertIn('background:#f9fafb;', markup)
|
||||
self.assertNotIn('background:#f0fdf4;', markup)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_actual_restore_endpoint_warnings_and_counts_reach_email(self):
|
||||
from notification_fixture import extract, SCRIPTS
|
||||
from types import SimpleNamespace
|
||||
for warnings in ('', 'missing module zfs'):
|
||||
events = []
|
||||
ns = {'request': SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
|
||||
'hostname': 'node-a', 'guests': '3', 'stubs': '1', 'stale_nodes': '0',
|
||||
'components': '2', 'duration': '2m', 'warnings': warnings}),
|
||||
'notification_manager': SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
|
||||
'jsonify': lambda value: value}
|
||||
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', None, ns)
|
||||
response, status = handler()
|
||||
self.assertEqual(status, 200)
|
||||
event = events[0]
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event['event_type'], event['data'], event['severity'], language)
|
||||
self.assertIn('2m', markup)
|
||||
for line in result['body'].splitlines():
|
||||
if line.strip(): self.assertIn(line.strip(), html.unescape(markup))
|
||||
if warnings: self.assertIn(warnings, markup)
|
||||
|
||||
|
||||
def test_raw_display_hostname_is_substituted_exactly_once(self):
|
||||
for event_type, outcome in (('backup_complete', 'confirmed'), ('backup_complete', 'failed'), ('backup_fail', 'failed')):
|
||||
for hostname in ('Sala {rack} – Zürich', 'node-{vmid}', 'Sala {rack.location}'):
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event_type, {'hostname': hostname,
|
||||
'backup_outcome': outcome, 'pve_message': REPORT}, language=language)
|
||||
self.assertIn(hostname, result['title'])
|
||||
self.assertIn(hostname, html.unescape(markup))
|
||||
|
||||
|
||||
def test_malformed_numeric_report_is_queued_uncertain_and_renderable(self):
|
||||
for size in ('1..5 GiB', '..5 GiB'):
|
||||
message = REPORT.replace('1 GiB ', size.ljust(9)).split('Total size:')[0]
|
||||
event = receive(message)
|
||||
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
|
||||
result, markup = email(event.event_type, event.data, event.severity)
|
||||
self.assertIn(size, result['body'])
|
||||
|
||||
|
||||
def test_unknown_backup_type_keeps_explicit_err_but_cannot_certify_ok(self):
|
||||
event = receive(REPORT.replace('ok ', 'err '), kind='')
|
||||
self.assertEqual(event.event_type, 'backup_complete')
|
||||
self.assertEqual(event.severity, 'INFO')
|
||||
self.assertEqual(event.data['backup_outcome'], 'failed')
|
||||
result, markup = email(event.event_type, event.data, event.severity)
|
||||
self.assertIn('FAILED', markup)
|
||||
self.assertEqual(receive(REPORT, kind='').data['backup_outcome'], 'unconfirmed')
|
||||
|
||||
|
||||
def test_confirmed_metadata_only_context_is_retained(self):
|
||||
data = {'hostname': 'node-a', 'backup_outcome': 'confirmed',
|
||||
'vmid': '100', 'vmname': 'web {literal}', 'storage': 'PBS', 'size': '1 GiB'}
|
||||
for language in LANGUAGES:
|
||||
result, markup = email('backup_complete', data, language=language)
|
||||
self.assertIn('web {literal} (100)', result['title'])
|
||||
self.assertIn('1 GiB', result['body'])
|
||||
self.assertIn('1 GiB', markup)
|
||||
|
||||
|
||||
def test_explicit_guest_failure_overrides_only_the_linked_ok_row(self):
|
||||
other = '101 db ok 1m 1s 1 GiB ct/101/2026-09-29T17:00:00Z'
|
||||
message = REPORT.replace('\n\nTotal', '\n' + other + '\n\nTotal')
|
||||
diagnostic = '100: 2026-09-29 17:00:00 ERROR: Backup of VM 100 failed - archive write failed'
|
||||
for severity in ('info', 'error'):
|
||||
event = receive(message + '\n' + diagnostic, severity)
|
||||
self.assertEqual(event.data.get('vmid'), '100')
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertIn('❌ VM web (100)', result['body'])
|
||||
self.assertNotIn('✅ VM web (100)', markup)
|
||||
self.assertIn('✅ CT db (101)', result['body'])
|
||||
self.assertIn('web (100)', result['title'])
|
||||
|
||||
|
||||
def test_official_week_month_year_durations_stay_confirmed(self):
|
||||
for duration in ('1w', '1w 1m 1s', '1M', '1y'):
|
||||
# Fixed-column widths are unchanged for these bounded values.
|
||||
message = REPORT.replace('1m 1s ', duration.ljust(9))
|
||||
event = receive(message)
|
||||
self.assertEqual(event.data['backup_outcome'], 'confirmed', duration)
|
||||
result, markup = email(event.event_type, event.data, event.severity)
|
||||
self.assertIn(duration, result['body'])
|
||||
|
||||
|
||||
def test_prefixed_error_diagnostics_survive_both_source_severities(self):
|
||||
diagnostic = '100: 2026-09-29 17:00:00 ERROR: archive write failed: permission denied'
|
||||
for severity in ('info', 'error'):
|
||||
event = receive(REPORT + '\n' + diagnostic, severity)
|
||||
self.assertEqual(event.data['backup_outcome'], 'failed')
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertIn(diagnostic, result['body'])
|
||||
self.assertIn(diagnostic, html.unescape(markup))
|
||||
# A job-level error must not invent a failed guest.
|
||||
self.assertIn('✅ VM web (100)', result['body'])
|
||||
|
||||
|
||||
def test_official_completed_report_warning_is_distinct_and_retained(self):
|
||||
warning = '100: 2026-09-29 17:00:00 WARN: unable to add notes - permission denied'
|
||||
event = receive(REPORT + '\n' + warning)
|
||||
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertIn(warning, result['body'])
|
||||
self.assertIn(warning, html.unescape(markup))
|
||||
|
||||
def test_batch_and_abort_failure_titles_have_no_empty_guest_slot(self):
|
||||
failed = REPORT.replace('ok ', 'err ')
|
||||
failed = failed.replace('\n\nTotal', '\n101 db err 1m 1s 0 B null\n\nTotal')
|
||||
for message in (failed, REPORT.replace('ok ', 'todo '),
|
||||
REPORT.split('100 web')[0] + '\nTotal running time: 0s'):
|
||||
event = receive(message + '\nINFO: vzdump --storage PBS', 'error')
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertNotIn('()', result['title'])
|
||||
self.assertIn('PBS', result['title'])
|
||||
self.assertNotIn('web (100)', result['title'])
|
||||
|
||||
def test_subject_only_setup_failure_survives_actual_email(self):
|
||||
reason = 'unable to activate storage PBS'
|
||||
title = 'vzdump backup status (node-a): backup failed: ' + reason
|
||||
message = REPORT.split('100 web')[0] + '\nTotal running time: 0s\nTotal size: 0 B'
|
||||
event = receive(message, 'error', title)
|
||||
for language in LANGUAGES:
|
||||
result, markup = email(event.event_type, event.data, event.severity, language)
|
||||
self.assertIn(reason, result['body'])
|
||||
self.assertIn(reason, html.unescape(markup))
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Final review contracts at actual locale/manual/queued/quiet email seams.
|
||||
All operational dependencies are inert; no manager or route module import.
|
||||
"""
|
||||
import ast
|
||||
import copy
|
||||
import html
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from notification_fixture import templates, SCRIPTS, LANGUAGES, receive
|
||||
from notification_final_fixture import deliver, restore_event
|
||||
|
||||
# Literal native send_notification body, produced by pinned PVE Perl helpers.
|
||||
NATIVE_REPORT = 'Details\n=======\n' + '''VMID Name Status Time Size Filename
|
||||
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
|
||||
|
||||
Total running time: 1m 1s
|
||||
Total size: 1 GiB
|
||||
|
||||
Logs
|
||||
====
|
||||
vzdump --all 1 --storage PBS --mode snapshot
|
||||
|
||||
100: no log available
|
||||
'''
|
||||
|
||||
|
||||
|
||||
class FinalCorrectionsTests(unittest.TestCase):
|
||||
def test_runtime_backup_assertions_accept_generated_and_missing_slovak_title(self):
|
||||
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
|
||||
tree = ast.parse(path.read_text())
|
||||
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'RuntimeCatalogTests')
|
||||
method = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == 'test_special_formatters_digest_and_test_message_are_slovak')
|
||||
start = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='backup' for t in n.targets))
|
||||
stop = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='manager' for t in n.targets))
|
||||
block = ast.Module(body=method.body[start:stop], type_ignores=[])
|
||||
english = templates._load_runtime_catalog('en')
|
||||
for title in (None, '{hostname}: GENERATED_SK potvrdené'):
|
||||
sk = copy.deepcopy(templates._load_runtime_catalog('sk'))
|
||||
if title is not None: sk.setdefault('backup', {})['confirmedTitle'] = title
|
||||
else: sk.get('backup', {}).pop('confirmedTitle', None)
|
||||
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else sk):
|
||||
exec(compile(block,str(path),'exec'), {'self':self, 'notification_templates':templates})
|
||||
|
||||
|
||||
def test_native_multiline_failure_diagnostics_survive_receiver_dispatch_once(self):
|
||||
subject = 'vzdump backup status (node-a): backup failed: multiple problems'
|
||||
for diagnostic, report in (
|
||||
('external provider job cleanup failed\njob-abort hook permission denied', NATIVE_REPORT),
|
||||
('job interrupted\njob-abort hook permission denied', NATIVE_REPORT.replace('ok ', 'todo ')),
|
||||
('unable to initialize external provider\njob-abort hook permission denied', NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z\n', '')),
|
||||
):
|
||||
event = receive(diagnostic + '\n' + report, 'error', subject)
|
||||
self.assertEqual((event.event_type, event.severity), ('backup_fail', 'CRITICAL'))
|
||||
for language in LANGUAGES:
|
||||
result = deliver(event.event_type, event.data, event.severity, language)
|
||||
for line in diagnostic.splitlines():
|
||||
self.assertEqual(result['body'].count(line), 1)
|
||||
self.assertEqual(result['text'].count(line), 1)
|
||||
if 'ok ' in report:
|
||||
self.assertIn('✅ VM web (100)', result['body'])
|
||||
|
||||
|
||||
def test_restore_endpoint_quiet_release_keeps_warning_counts_and_truthful_footer(self):
|
||||
reason = 'missing module zfs <raw> & {literal}'
|
||||
event = restore_event(reason)
|
||||
self.assertEqual(event['severity'], 'WARNING')
|
||||
for language in LANGUAGES:
|
||||
result = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
|
||||
self.assertEqual(result['severity'], 'INFO')
|
||||
buffered_body = result['buffered'][0][3]
|
||||
self.assertEqual(result['text'].count(reason), 1)
|
||||
for line in buffered_body.splitlines():
|
||||
if line.strip(): self.assertIn(line.strip(), result['text'])
|
||||
self.assertIn('2m', result['text'])
|
||||
self.assertNotIn(templates.runtime_message('digest.footer', language), result['body'])
|
||||
self.assertNotIn('script', result['tags'])
|
||||
|
||||
|
||||
|
||||
|
||||
def test_raw_restore_cells_use_event_scoped_mail_wrapping(self):
|
||||
token = 'b' * 64
|
||||
event = restore_event('Boot check: recorded token ' + token + '; verification pending')
|
||||
for language in LANGUAGES:
|
||||
results = [deliver(event['event_type'],event['data'],event['severity'],language,quiet=quiet) for quiet in (False,True)]
|
||||
for result in results:
|
||||
self.assertIn('table-layout:fixed;', result['html'])
|
||||
self.assertIn('word-wrap:break-word;', result['html'])
|
||||
self.assertIn('overflow-wrap:break-word;', result['html'])
|
||||
self.assertEqual(result['text'].count(token), 1)
|
||||
unrelated = deliver('node_reconnect', {'hostname':'node-a'}, 'OK')
|
||||
self.assertNotIn('table-layout:fixed;', unrelated['html'])
|
||||
|
||||
|
||||
def test_manual_failed_and_unconfirmed_backup_keep_short_actionable_reason(self):
|
||||
for outcome, reason in (('failed','PBS permission denied for datastore remote'),
|
||||
('unconfirmed','Task status unavailable: upstream API timed out')):
|
||||
for language in LANGUAGES:
|
||||
result = deliver('backup_complete',{'hostname':'node-a','vmid':'100','vmname':'web',
|
||||
'storage':'PBS','backup_outcome':outcome,'reason':reason},'INFO',language,manual=True)
|
||||
self.assertEqual(result['text'].count(reason), 1)
|
||||
self.assertNotIn('>COMPLETED</span>', result['html'])
|
||||
|
||||
|
||||
def test_backup_reason_threshold_and_raw_body_deduplication(self):
|
||||
for event in ('backup_complete', 'backup_fail'):
|
||||
for length in (79, 80, 81, 120):
|
||||
prefix = '<raw> & {rack.location} '
|
||||
reason = prefix + 'b' * (length - len(prefix))
|
||||
self.assertEqual(len(reason), length)
|
||||
for raw in ('', reason):
|
||||
data = {'hostname':'node-a','backup_outcome':'failed','reason':reason,'pve_message':raw}
|
||||
for manual in (False, True):
|
||||
result = deliver(event,data,'INFO','en',manual=manual)
|
||||
self.assertEqual(result['text'].count(reason), 1, (event,length,raw,manual))
|
||||
self.assertNotIn('raw', result['tags'])
|
||||
|
||||
|
||||
def test_quiet_restore_after_preview_limit_is_not_omitted_or_called_info(self):
|
||||
event = restore_event('missing module zfs')
|
||||
earlier = [dict(event_type='service_fail',severity='WARNING',data={'hostname':'node-a','service_name':f'unit-{i}','reason':'recorded'}) for i in range(8)]
|
||||
result = deliver(event['event_type'],event['data'],event['severity'],quiet=True,quiet_before=earlier)
|
||||
self.assertEqual(result['text'].count('missing module zfs'), 1)
|
||||
self.assertNotIn(templates.runtime_message('digest.lead','en',count=9), result['body'])
|
||||
self.assertEqual(result['data']['_count'], 9)
|
||||
|
||||
|
||||
def test_native_error_block_does_not_deduplicate_a_substring_of_inventory(self):
|
||||
event = receive('web\nexternal provider job cleanup failed\n' + NATIVE_REPORT,
|
||||
'error','vzdump backup status (node-a): backup failed: multiple problems')
|
||||
result = deliver(event.event_type,event.data,event.severity)
|
||||
self.assertEqual(result['body'].splitlines().count('web'), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The notification settings show the delivery history the Monitor records,
|
||||
the daily digest runs included, and never offer to erase it: Audit & Report
|
||||
reads the same history to tell whether each channel delivers."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PAGE = ROOT / 'AppImage/components/notification-settings.tsx'
|
||||
KEYS = ('title', 'description', 'empty', 'loadFailed', 'sent', 'notSent',
|
||||
'severityInfo', 'severityWarning', 'severityCritical')
|
||||
|
||||
|
||||
class NotificationHistoryPanelTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.page = PAGE.read_text(encoding='utf-8')
|
||||
|
||||
def test_the_panel_opens_and_lists_the_history(self):
|
||||
self.assertIn('setShowHistory(open => !open)', self.page)
|
||||
self.assertIn('history.map(entry =>', self.page)
|
||||
self.assertIn('t("settings.notifications.history.title")', self.page)
|
||||
|
||||
def test_the_history_is_read_without_a_success_flag(self):
|
||||
loader = re.search(r'const loadHistory = useCallback\(async \(\) => \{(.*?)\n \}, \[\]\)', self.page, re.S)
|
||||
self.assertIsNotNone(loader)
|
||||
self.assertIn('Array.isArray(data.history)', loader.group(1))
|
||||
self.assertNotIn('data.success', loader.group(1))
|
||||
|
||||
def test_a_digest_run_and_a_failed_delivery_show_their_message(self):
|
||||
self.assertIn('entry.event_type === "digest" ? entry.message', self.page)
|
||||
self.assertIn('!sent ? entry.error_message', self.page)
|
||||
|
||||
def test_the_page_never_erases_the_history(self):
|
||||
self.assertNotIn('handleClearHistory', self.page)
|
||||
self.assertIsNone(re.search(r'notifications/history["\'`][^)]*method:\s*["\']DELETE', self.page))
|
||||
|
||||
def test_the_texts_exist_in_english_and_spanish(self):
|
||||
for language in ('en', 'es'):
|
||||
catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8'))
|
||||
history = catalog['settings']['notifications']['history']
|
||||
for key in KEYS:
|
||||
with self.subTest(language=language, key=key):
|
||||
self.assertTrue(history.get(key))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Maintainer acceptance at inert actual notification consumers."""
|
||||
import unittest
|
||||
from notification_fixture import templates, receive, LANGUAGES, SCRIPTS, extract
|
||||
from notification_final_fixture import deliver
|
||||
|
||||
NATIVE_REPORT = '''Details
|
||||
=======
|
||||
VMID Name Status Time Size Filename
|
||||
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
|
||||
|
||||
Total running time: 1m 1s
|
||||
Total size: 1 GiB
|
||||
|
||||
Logs
|
||||
====
|
||||
vzdump --all 1 --storage PBS --mode snapshot
|
||||
|
||||
100: 2026-09-29 17:00:00 INFO: Starting Backup of VM 100 (qemu)
|
||||
100: 2026-09-29 17:01:01 INFO: Finished Backup of VM 100 (00:01:01)
|
||||
'''
|
||||
|
||||
class MaintainerFollowupTests(unittest.TestCase):
|
||||
def test_completed_with_warnings_requires_independent_completion(self):
|
||||
warning = '\n100: 2026-09-29 17:00:01 WARN: file changed during backup'
|
||||
event = receive(NATIVE_REPORT + warning)
|
||||
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
|
||||
self.assertEqual((event.event_type,event.severity), ('backup_complete','INFO'))
|
||||
for manual in (False,True):
|
||||
for lang in LANGUAGES:
|
||||
result = deliver(event.event_type,event.data,event.severity,lang,manual=manual)
|
||||
label = templates.runtime_message('backup.warningTitle',lang,hostname='node-a')
|
||||
self.assertIn(label,result['title'])
|
||||
status = templates.runtime_message('channels.email.status.completed_with_warnings',lang)
|
||||
self.assertIn(status,result['text'])
|
||||
self.assertIn('file changed during backup',result['text'])
|
||||
# Manual sends intentionally skip channel emoji enrichment.
|
||||
if not manual: self.assertTrue(result['title'].startswith('💾⚠️'))
|
||||
rich, _ = templates.enrich_with_emojis(event.event_type,result['title'],result['body'],event.data)
|
||||
self.assertTrue(rich.startswith('💾⚠️'))
|
||||
self.assertEqual(receive('WARN: file changed during backup').data['backup_outcome'],'unconfirmed')
|
||||
self.assertEqual(receive(NATIVE_REPORT.split('Total running time:')[0]+warning).data['backup_outcome'],'unconfirmed')
|
||||
self.assertEqual(receive(NATIVE_REPORT+warning+'\nERROR: cleanup failed').data['backup_outcome'],'failed')
|
||||
self.assertEqual(receive(NATIVE_REPORT+warning,'warning').data['backup_outcome'],'completed_with_warnings')
|
||||
self.assertEqual(receive('INFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'+warning).data['backup_outcome'],'completed_with_warnings')
|
||||
self.assertEqual(receive(NATIVE_REPORT+warning,kind='').data['backup_outcome'],'unconfirmed')
|
||||
|
||||
def test_null_filename_failed_guest_uses_own_start_identity(self):
|
||||
report = NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z',
|
||||
'100 web err 1m 1s 0 B null')
|
||||
for kind, prefix in (('qemu','VM'),('lxc','CT')):
|
||||
own = report.replace('VM 100 (qemu)',f'VM 100 ({kind})')
|
||||
# Unrelated guest appears first and must never supply the failed type.
|
||||
message = f'INFO: Starting Backup of VM 999 ({"lxc" if kind == "qemu" else "qemu"})\n' + own
|
||||
event = receive(message,'error','vzdump backup status (raw-node): backup failed')
|
||||
parsed = templates._parse_vzdump_message(message)
|
||||
self.assertEqual(parsed['vms'][0]['type'],kind)
|
||||
result = deliver(event.event_type,event.data,event.severity)
|
||||
self.assertIn(f'{prefix} web (100)',result['title'])
|
||||
self.assertIn(f'❌ {prefix} web (100)',result['body'])
|
||||
for message in (report.replace('Starting Backup of VM 100','Starting Backup of VM 999'),
|
||||
report+'\nINFO: Starting Backup of VM 100 (lxc)'):
|
||||
self.assertEqual(templates._parse_vzdump_message(message)['vms'][0]['type'],'')
|
||||
|
||||
def test_native_subject_keeps_cause_without_host_envelope(self):
|
||||
subject = 'vzdump backup status (raw-host): backup failed: multiple problems'
|
||||
event = receive('ERROR: archive write failed\n'+NATIVE_REPORT,'error',subject)
|
||||
event.data['hostname']='configured-alias'
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang)
|
||||
self.assertNotIn(subject,result['text'])
|
||||
self.assertEqual(result['text'].count('ERROR: archive write failed'),1)
|
||||
self.assertIn('configured-alias',result['title'])
|
||||
setup=receive('Details\n=======\nVMID Name Status Time Size Filename\n\nTotal running time: 0s\nTotal size: 0 B','error',subject.replace('multiple problems','unable to open storage'))
|
||||
result=deliver(setup.event_type,setup.data,setup.severity)
|
||||
self.assertEqual(result['text'].count('unable to open storage'),1)
|
||||
self.assertNotIn(setup.data['pve_title'],result['text'])
|
||||
unique=receive(NATIVE_REPORT,'error',subject.replace('multiple problems','job-end hook denied'))
|
||||
result=deliver(unique.event_type,unique.data,unique.severity)
|
||||
self.assertIn('job-end hook denied',result['body'])
|
||||
self.assertNotIn('vzdump backup status',result['body'])
|
||||
|
||||
def test_backup_diagnostics_are_bounded_with_principal_cause_and_notice(self):
|
||||
raw = NATIVE_REPORT + '\n' + '\n'.join(f'WARN: repeated warning {i}' for i in range(80)) + '\nERROR: principal archive write failure'
|
||||
event=receive(raw)
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang)
|
||||
self.assertIn('ERROR: principal archive write failure',result['body'])
|
||||
self.assertLessEqual(len('\n'.join(line for line in result['body'].splitlines() if line.startswith(('WARN:','ERROR:')))),1024)
|
||||
self.assertLessEqual(sum(line.startswith(('WARN:','ERROR:')) for line in result['body'].splitlines()),8)
|
||||
notice=templates.runtime_message('backup.diagnosticsOmitted',lang,count=73)
|
||||
self.assertIn(notice,result['body'])
|
||||
self.assertEqual(event.data['pve_message'],raw)
|
||||
long=receive('ERROR: '+ 'b'*5000,'error','vzdump backup status (node): backup failed')
|
||||
result=deliver(long.event_type,long.data,long.severity)
|
||||
self.assertLess(len(result['body']),1400)
|
||||
self.assertIn('ERROR: '+ 'b'*100,result['body'])
|
||||
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted','en',count=1),result['body'])
|
||||
|
||||
def test_real_quiet_digest_retains_each_backup_outcome_icon(self):
|
||||
samples=[(NATIVE_REPORT,'confirmed','💾✅'),(NATIVE_REPORT+'\nWARN: changed file','completed_with_warnings','💾⚠️'),(NATIVE_REPORT+'\nERROR: write failed','failed','💾❌'),('INFO: Starting Backup of VM 100 (qemu)','unconfirmed','💾❔')]
|
||||
for raw,outcome,icon in samples:
|
||||
event=receive(raw)
|
||||
self.assertEqual(event.data['backup_outcome'],outcome)
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang,quiet=True)
|
||||
self.assertIn(icon,result['body'])
|
||||
self.assertNotIn('💾❔',result['body']) if outcome!='unconfirmed' else None
|
||||
import types,datetime
|
||||
ns={'datetime':datetime.datetime,'runtime_message':templates.runtime_message,'EVENT_EMOJI':templates.EVENT_EMOJI,'CATEGORY_EMOJI':templates.CATEGORY_EMOJI}
|
||||
compose=extract(SCRIPTS/'notification_manager.py','_compose_digest_body','NotificationManager',ns)
|
||||
target=types.SimpleNamespace(_notification_language=lambda:'en')
|
||||
rows=[(i,'backup_complete','backup',1,icon+' node: Backup','') for i,(_,_,icon) in enumerate(samples)]
|
||||
body=compose(target,rows,use_icons=True)
|
||||
for _,_,icon in samples:self.assertIn(icon,body)
|
||||
plain=compose(target,rows,use_icons=False)
|
||||
for _,_,icon in samples:self.assertNotIn(icon,plain)
|
||||
should=extract(SCRIPTS/'notification_manager.py','_should_buffer_for_digest','NotificationManager',{})
|
||||
self.assertFalse(should(types.SimpleNamespace(_DIGEST_EXEMPT_EVENTS={'backup_complete'},_config={'email.digest_enabled':'true'}),'email','INFO','backup_complete'))
|
||||
|
||||
def test_quiet_restore_details_are_subordinate_in_text_and_email(self):
|
||||
from notification_final_fixture import restore_event
|
||||
event=restore_event('missing module zfs')
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event['event_type'],event['data'],event['severity'],lang,quiet=True)
|
||||
body_lines=result['buffered'][0][3].splitlines()
|
||||
for line in body_lines:
|
||||
if line.strip():
|
||||
self.assertIn(' '+line.strip(),result['body'])
|
||||
self.assertIn(' '+__import__('html').escape(line.strip()),result['html'])
|
||||
self.assertIn('white-space:pre-wrap;',result['html'])
|
||||
self.assertNotIn(templates.runtime_message('digest.footer',lang),result['body'])
|
||||
|
||||
def test_concrete_subject_cause_already_in_error_log_is_not_repeated(self):
|
||||
event=receive(NATIVE_REPORT+'\n100: ERROR: job-end hook denied','error',
|
||||
'vzdump backup status (raw-host): backup failed: job-end hook denied')
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang)
|
||||
self.assertEqual(result['text'].count('job-end hook denied'),1)
|
||||
self.assertIn('100: ERROR: job-end hook denied',result['body'])
|
||||
|
||||
def test_job_level_subject_cause_survives_warning_cap(self):
|
||||
raw=NATIVE_REPORT+'\n'+'\n'.join('WARN: repeated diagnostic '+str(i) for i in range(80))
|
||||
event=receive(raw,'error','vzdump backup status (raw-host): backup failed: job-end hook denied')
|
||||
for lang in LANGUAGES:
|
||||
result=deliver(event.event_type,event.data,event.severity,lang)
|
||||
self.assertEqual(result['body'].count('job-end hook denied'),1)
|
||||
self.assertNotIn('vzdump backup status',result['body'])
|
||||
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted',lang,count=73),result['body'])
|
||||
|
||||
def test_backup_quiet_email_uses_event_scoped_wrapping(self):
|
||||
event=receive(NATIVE_REPORT+'\nWARN: changed file')
|
||||
result=deliver(event.event_type,event.data,event.severity,'sv',quiet=True)
|
||||
self.assertTrue(result['data'].get('_backup_summary'))
|
||||
self.assertIn('table-layout:fixed;',result['html'])
|
||||
self.assertIn('overflow-wrap:break-word;',result['html'])
|
||||
unrelated=deliver('node_reconnect',{'hostname':'node-a'},'OK')
|
||||
self.assertNotIn('table-layout:fixed;',unrelated['html'])
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Inert producer-to-renderer checks for notification outcome claims."""
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from notification_fixture import templates as actual_templates
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / 'AppImage/scripts'
|
||||
CATALOG = ROOT / 'AppImage/messages/en/common.json'
|
||||
EXPECTED = {'system_restore_completed': {'body': 'Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}'}}
|
||||
|
||||
|
||||
def extract(path, name, owner=None, namespace=None):
|
||||
tree = ast.parse(path.read_text())
|
||||
nodes = tree.body
|
||||
if owner:
|
||||
nodes = next(n.body for n in nodes if isinstance(n, ast.ClassDef) and n.name == owner)
|
||||
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
ns = namespace if namespace is not None else {}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
|
||||
return ns[name]
|
||||
|
||||
|
||||
def renderer(catalog, translated=None):
|
||||
path = SCRIPTS / 'notification_templates.py'
|
||||
tree = ast.parse(path.read_text())
|
||||
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
|
||||
def lookup(obj, key):
|
||||
for part in key.split('.'):
|
||||
obj = obj.get(part) if isinstance(obj, dict) else None
|
||||
return obj
|
||||
def message(key, language='en', **values):
|
||||
source = translated if language == 'it' and translated is not None else catalog
|
||||
namespace = source['runtime']['notifications']
|
||||
value = lookup(namespace, key) or lookup(catalog['runtime']['notifications'], key) or ''
|
||||
return value.format_map(type('Safe', (dict,), {'__missing__': lambda self, k: ''})(values))
|
||||
ns = {'TEMPLATES': templates, 'Dict': dict, 'Any': Any, 'time': time, 're': re,
|
||||
'_get_hostname': lambda: 'node-a',
|
||||
'_load_runtime_catalog': lambda lang: (translated if lang == 'it' and translated is not None
|
||||
else catalog)['runtime']['notifications'],
|
||||
'_catalog_value': lookup, 'runtime_message': message}
|
||||
from typing import Optional
|
||||
ns['Optional'] = Optional
|
||||
extract(path, '_parse_vzdump_table', namespace=ns)
|
||||
extract(path, '_parse_vzdump_message', namespace=ns)
|
||||
extract(path, '_format_vzdump_body', namespace=ns)
|
||||
return templates, extract(path, 'render_template', namespace=ns)
|
||||
|
||||
|
||||
class OutcomeWording(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.catalog = json.loads(CATALOG.read_text())
|
||||
cls.templates, render = renderer(cls.catalog)
|
||||
cls.render = staticmethod(render)
|
||||
|
||||
def test_spanish_restore_uses_maintainer_guests_terminology(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('isolated_spanish_restore', SCRIPTS / 'notification_templates.py')
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
result = module.render_template('system_restore_completed', {
|
||||
'hostname':'node-a','guests':3,'stubs':0,'stale_nodes':0,
|
||||
'components':1,'duration':'2m','warnings_block':''}, 'es')
|
||||
self.assertIn('Configuración de guests copiada: 3', result['body'])
|
||||
self.assertNotIn('invitados', result['body'].lower())
|
||||
|
||||
def test_settings_labels_stay_at_upstream_values_in_all_locales(self):
|
||||
# Frozen from develop eb7cc548; CI shallow checkouts have no base history.
|
||||
labels = {'en': {'backup_complete': 'Backup complete', 'error_resolved': 'Recovery notification'}, 'de': {'backup_complete': 'Sicherung abgeschlossen', 'error_resolved': 'Wiederherstellungsbenachrichtigung'}, 'es': {'backup_complete': 'Backup completado', 'error_resolved': 'Notificación de recuperación'}, 'fr': {'backup_complete': 'Sauvegarde terminée', 'error_resolved': 'Notification de récupération'}, 'it': {'backup_complete': 'Backup completato', 'error_resolved': 'Notifica di recupero'}, 'pt': {'backup_complete': 'Backup concluído', 'error_resolved': 'Notificação de recuperação'}, 'sk': {'backup_complete': 'Záloha bola dokončená', 'error_resolved': 'Problém bol vyriešený'}, 'sv': {'backup_complete': 'Säkerhetskopieringen är klar', 'error_resolved': 'Återställningsmeddelande'}}
|
||||
for lang in labels:
|
||||
path = f'AppImage/messages/{lang}/common.json'
|
||||
current = json.loads((ROOT / path).read_text())
|
||||
for event in ('backup_complete', 'error_resolved'):
|
||||
with self.subTest(lang=lang, event=event):
|
||||
expected = labels[lang][event]
|
||||
self.assertEqual(current['runtime']['notifications']['templates'][event]['label'], expected)
|
||||
if lang == 'en': self.assertEqual(self.templates[event]['label'], expected)
|
||||
|
||||
def test_exact_four_english_leaves_match_source_and_catalog(self):
|
||||
for event, fields in EXPECTED.items():
|
||||
for field, value in fields.items():
|
||||
with self.subTest(event=event, field=field):
|
||||
self.assertEqual(self.templates[event][field], value)
|
||||
self.assertEqual(self.catalog['runtime']['notifications']['templates'][event][field], value)
|
||||
|
||||
def test_webhook_backup_outcome_is_evidence_based_without_rerouting(self):
|
||||
path = SCRIPTS / 'notification_events.py'
|
||||
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
|
||||
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
|
||||
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
|
||||
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
|
||||
class Event:
|
||||
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, event): self.items.append(event)
|
||||
ns['NotificationEvent'] = Event
|
||||
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = classify
|
||||
_map_severity = staticmethod(severity_map)
|
||||
_backup_outcome = staticmethod(backup_outcome)
|
||||
def __init__(self): self._queue = Queue()
|
||||
header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
|
||||
row_ok = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','OK','00:01:00','1.5 GiB','archive')
|
||||
row_warning = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','WARNINGS','00:01:00','1.5 GiB','archive')
|
||||
row_error = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','ERROR','00:01:00','1.5 GiB','archive')
|
||||
row_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','err','00:01:00','1.5 GiB','archive')
|
||||
truncated = 'INFO: Log output was too long to be displayed. Please see task log for details.'
|
||||
cases = [
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_err+'\nTotal running time: 00:02:00', 'failed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\n'+header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_err+'\nTotal running time: 00:01:00\n'+truncated, 'failed'),
|
||||
('vzdump', 'warning', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'completed_with_warnings'),
|
||||
('vzdump', 'info', header+'\n'+row_ok, 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_ok, 'unconfirmed'),
|
||||
('vzdump', 'warning', header+'\n'+row_err+'\nTotal running time: 00:01:00', 'failed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00\n'+truncated, 'unconfirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\nERROR: archive write failed', 'failed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
|
||||
('vzdump', 'info', header+'\n'+row_error, 'failed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'confirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00', 'unconfirmed'),
|
||||
('vzdump', 'info', header+'\n'+row_ok[:30], 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'confirmed'),
|
||||
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'completed_with_warnings'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)', 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nWARNING: skipped file', 'completed_with_warnings'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: TASK OK\n104 alpha WARNINGS: 1', 'completed_with_warnings'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Finished Backup of VM 105 (00:01:00)', 'confirmed'),
|
||||
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed for VM 104', 'failed'),
|
||||
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'failed'),
|
||||
('', 'warning', 'Backup scheduled', 'unconfirmed'),
|
||||
('', 'info', 'Backup complete', 'unconfirmed'),
|
||||
]
|
||||
for kind, severity, message, expected in cases:
|
||||
with self.subTest(message=message, severity=severity):
|
||||
receiver = Receiver()
|
||||
receive(receiver, {'fields': {'type': kind}, 'severity': severity,
|
||||
'title': 'Backup', 'message': message})
|
||||
event = receiver._queue.items[0]
|
||||
self.assertEqual(event.event_type, 'backup_complete')
|
||||
self.assertEqual(event.data['backup_outcome'], expected)
|
||||
|
||||
def test_backup_classifier_preserves_confirmed_and_unverified_paths(self):
|
||||
path = SCRIPTS / 'notification_events.py'
|
||||
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
|
||||
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
|
||||
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
|
||||
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
|
||||
class Event:
|
||||
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, event): self.items.append(event)
|
||||
ns['NotificationEvent'] = Event
|
||||
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = classify
|
||||
_map_severity = staticmethod(severity_map)
|
||||
_backup_outcome = staticmethod(backup_outcome)
|
||||
def __init__(self): self._queue = Queue()
|
||||
samples = [('vzdump', 'info', 'Backup', 'job finished'),
|
||||
('vzdump', 'warning', 'Backup', 'job incomplete'),
|
||||
('', 'warning', 'backup job', 'Backup scheduled')]
|
||||
for kind, severity, title, message in samples:
|
||||
with self.subTest(kind=kind, message=message):
|
||||
event, entity, _ = classify(None, kind, severity, title, message)
|
||||
self.assertEqual((event, entity), ('backup_complete', 'vm'))
|
||||
receiver = Receiver()
|
||||
reply = receive(receiver, {'fields': {'type': kind}, 'severity': severity,
|
||||
'title': title, 'message': message})
|
||||
self.assertEqual(reply['event_type'], event)
|
||||
self.assertEqual(len(receiver._queue.items), 1)
|
||||
emitted = receiver._queue.items[0]
|
||||
self.assertEqual(emitted.severity, 'WARNING' if severity == 'warning' else 'INFO')
|
||||
output = self.render(emitted.event_type, emitted.data, 'en')
|
||||
self.assertIn('Backup outcome unconfirmed', output['title'])
|
||||
self.assertEqual(output['body'].splitlines()[-1], message) # raw PVE body retained
|
||||
self.assertEqual(classify(None, 'vzdump', 'error', 'Backup', 'failed')[0], 'backup_fail')
|
||||
|
||||
def test_incomplete_vzdump_log_does_not_certify_a_guest(self):
|
||||
from typing import Dict, Optional
|
||||
ns = {'re': re, 'Dict': Dict, 'Optional': Optional, 'Any': Any,
|
||||
'runtime_message': lambda key, lang, **kw: key}
|
||||
extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_table', namespace=ns)
|
||||
parser = extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_message', namespace=ns)
|
||||
formatter = extract(SCRIPTS / 'notification_templates.py', '_format_vzdump_body', namespace=ns)
|
||||
incomplete = parser('INFO: Starting Backup of VM 104 (qemu)')
|
||||
table_header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
|
||||
table_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','err','00:01:00','1.5 GiB','archive')
|
||||
failed_table = parser(table_header+'\n'+table_err+'\nTotal running time: 00:01:00')
|
||||
self.assertEqual(failed_table['vms'][0]['status'].lower(), 'error')
|
||||
self.assertIn('❌', formatter(failed_table, False, 'en'))
|
||||
self.assertEqual(incomplete['vms'][0]['status'], 'unknown')
|
||||
self.assertNotIn('✅', formatter(incomplete, False, 'en'))
|
||||
mixed = parser('INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Starting Backup of VM 105 (lxc)')
|
||||
self.assertEqual([vm['status'] for vm in mixed['vms']], ['ok', 'unknown'])
|
||||
formatted = formatter(mixed, False, 'en')
|
||||
self.assertEqual(formatted.count('✅'), 1)
|
||||
self.assertNotIn('❌', formatted)
|
||||
conflicting = parser('INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed\nINFO: Finished Backup of VM 104 (00:01:00)')
|
||||
self.assertEqual(conflicting['vms'][0]['status'], 'error')
|
||||
|
||||
def test_backup_render_preserves_success_and_marks_unconfirmed_and_failure(self):
|
||||
for outcome, message, title_part, body_part in (
|
||||
('confirmed', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'Backup complete', '✅'),
|
||||
('unconfirmed', 'INFO: Starting Backup of VM 104 (qemu)', 'Backup outcome unconfirmed', '❔'),
|
||||
('failed', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'Backup error reported', '❌'),
|
||||
):
|
||||
with self.subTest(outcome=outcome):
|
||||
output = self.render('backup_complete', {'hostname': 'node-a', 'pve_type': 'vzdump',
|
||||
'pve_message': message, 'pve_title': 'Backup complete', 'backup_outcome': outcome}, 'en')
|
||||
self.assertIn(title_part, output['title'])
|
||||
self.assertIn(body_part, output['body'])
|
||||
if outcome != 'confirmed': self.assertNotIn('Backup complete', output['title'])
|
||||
output = self.render('backup_complete', {'hostname': 'node-a', 'vmname': 'vm', 'vmid': '104'}, 'en')
|
||||
self.assertIn('Backup outcome unconfirmed', output['title'])
|
||||
self.assertNotIn('successfully', output['body'])
|
||||
conflict = self.render('backup_complete', {'hostname': 'node-a','backup_outcome':'failed',
|
||||
'pve_message':'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nERROR: archive write failed'}, 'en')
|
||||
self.assertIn('ERROR: archive write failed', conflict['body'])
|
||||
self.assertNotIn('Backup complete', conflict['title'])
|
||||
|
||||
def test_confirmed_title_keeps_single_guest_and_destination_without_misnaming_batches(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('isolated_backup_title', SCRIPTS / 'notification_templates.py')
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
actual_render = module.render_template
|
||||
log = ('INFO: starting new backup job: vzdump 104 --storage PBS-Cloud --mode snapshot\n'
|
||||
'INFO: Starting Backup of VM 104 (qemu)\nINFO: VM Name: Alpha\n'
|
||||
'INFO: Finished Backup of VM 104 (00:01:00)')
|
||||
single = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
|
||||
'pve_message':log}, 'en')
|
||||
self.assertIn('PBS-Cloud',single['title'])
|
||||
self.assertIn('VM Alpha (104)',single['title'])
|
||||
batch = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
|
||||
'pve_message':log+'\nINFO: Starting Backup of VM 105 (lxc)\n'
|
||||
'INFO: Finished Backup of VM 105 (00:01:00)'}, 'en')
|
||||
self.assertIn('PBS-Cloud',batch['title'])
|
||||
self.assertNotIn('Alpha (104)',batch['title'])
|
||||
no_context = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed'}, 'en')
|
||||
self.assertEqual(no_context['title'], 'node-a: Backup complete')
|
||||
named = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
|
||||
'pve_message':log.replace('Alpha','Alpha {literal}')}, 'en')
|
||||
self.assertIn('Alpha {literal} (104)', named['title'])
|
||||
|
||||
def test_html_email_badge_and_backup_status_are_context_specific(self):
|
||||
import html
|
||||
path = SCRIPTS / 'notification_channels.py'
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
with self.subTest(lang=lang):
|
||||
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
|
||||
english = self.catalog['runtime']['notifications']
|
||||
def text(key, data=None, **values):
|
||||
def lookup(source):
|
||||
value = source['channels']
|
||||
for part in key.split('.'):
|
||||
value = value.get(part) if isinstance(value, dict) else None
|
||||
return value
|
||||
return (lookup(catalog) or lookup(english) or '').format(**values)
|
||||
ns = {'Dict': dict, 'Optional': __import__('typing').Optional,
|
||||
'_runtime_text': text, '_runtime_notification_text': lambda key, data=None: ''}
|
||||
build = extract(path, '_build_detail_rows', 'EmailChannel', ns)
|
||||
fmt = extract(path, '_format_html', 'EmailChannel', ns)
|
||||
class Email:
|
||||
from notification_channels import EmailChannel
|
||||
_SEV_STYLE = EmailChannel._SEV_STYLE
|
||||
_SEV_DEFAULT = EmailChannel._SEV_DEFAULT
|
||||
subject_prefix = 'ProxMenux'
|
||||
_build_detail_rows = staticmethod(build)
|
||||
unrelated = fmt(Email(), 'Reconnected', 'Body', 'OK', {'_event_type': 'node_reconnect',
|
||||
'_notification_language': lang, '_group': 'cluster'})
|
||||
self.assertIn('>' + catalog['channels']['email']['severity']['ok'].upper() + '</span>', unrelated)
|
||||
for outcome, status in [('confirmed', 'completed'), ('unconfirmed','unconfirmed'), ('failed','failed')]:
|
||||
email = fmt(Email(), 'Backup', 'Details', 'INFO', {'_event_type': 'backup_complete',
|
||||
'backup_outcome': outcome, '_notification_language': lang, '_group': 'backup'})
|
||||
label = catalog['channels']['email']['status'].get(status) or english['channels']['email']['status'][status]
|
||||
self.assertIn(label, html.unescape(email))
|
||||
badge_label = label.upper()
|
||||
self.assertIn('>' + badge_label + '</span>', html.unescape(email))
|
||||
if outcome == 'failed':
|
||||
self.assertIn('color:#dc2626;font-weight:600;', email)
|
||||
self.assertIn('background:#fef2f2;', email)
|
||||
elif outcome == 'unconfirmed':
|
||||
self.assertIn('background:#f9fafb;', email)
|
||||
else:
|
||||
self.assertIn('background:#f0fdf4;', email)
|
||||
|
||||
def test_actual_all_locale_rendering_and_fallback_for_backup_outcomes(self):
|
||||
import importlib.util
|
||||
module_path = SCRIPTS / 'notification_templates.py'
|
||||
spec = importlib.util.spec_from_file_location('isolated_notification_templates', module_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # templates and catalogs only; no manager or sends
|
||||
samples = {
|
||||
'confirmed': 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)',
|
||||
'unconfirmed': 'INFO: Starting Backup of VM 104 (qemu)',
|
||||
'failed': 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed',
|
||||
}
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
|
||||
for state, message in samples.items():
|
||||
with self.subTest(lang=lang, state=state):
|
||||
data = {'hostname':'node-with-a-long-name','backup_outcome':state,'pve_type':'vzdump',
|
||||
'pve_message':message, 'pve_title':'Backup complete', '_notification_language':lang}
|
||||
result = module.render_template('backup_complete', data, lang)
|
||||
if state != 'unconfirmed':
|
||||
key = 'confirmedTitle' if state == 'confirmed' else 'errorTitle'
|
||||
expected_title = (catalog.get('backup', {}).get(key) or
|
||||
self.catalog['runtime']['notifications']['backup'][key]).format(hostname=data['hostname'])
|
||||
self.assertTrue(result['title'].startswith(expected_title), result['title'])
|
||||
else:
|
||||
self.assertEqual(result['title'], module.runtime_message('backup.unconfirmedTitle', lang, hostname=data['hostname']))
|
||||
self.assertNotIn('{hostname}', result['title'])
|
||||
if state == 'unconfirmed':
|
||||
source = (catalog if catalog.get('backup', {}).get('unconfirmedBody')
|
||||
else self.catalog['runtime']['notifications'])
|
||||
self.assertIn(source['backup']['unconfirmedBody'], result['body'])
|
||||
if state == 'failed':
|
||||
self.assertIn(catalog.get('backup', {}).get('errorBody') or
|
||||
self.catalog['runtime']['notifications']['backup']['errorBody'], result['body'])
|
||||
enriched, _ = module.enrich_with_emojis('backup_complete', result['title'], result['body'], data)
|
||||
self.assertTrue(enriched.startswith({'confirmed':'💾✅','unconfirmed':'💾❔','failed':'💾❌'}[state]))
|
||||
restore = module.render_template('system_restore_completed', {'hostname':'node', 'guests':4,
|
||||
'stubs':1,'stale_nodes':2,'components':1,'duration':'2m','warnings_block':'Missing module'},lang)
|
||||
self.assertIn('Missing module',restore['body'])
|
||||
if lang == 'en': self.assertNotIn('fully ready',restore['body'].lower())
|
||||
|
||||
|
||||
|
||||
def test_warning_and_clean_restore_keep_only_reported_outcome(self):
|
||||
for warnings in ('', '⚠️ Boot sanity: missing modules\n'):
|
||||
with self.subTest(warnings=warnings):
|
||||
events = []
|
||||
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
|
||||
'hostname': 'node-a', 'guests': '2', 'stubs': '0', 'stale_nodes': '0',
|
||||
'components': 'none', 'duration': '2m',
|
||||
'warnings': 'missing modules' if warnings else ''}),
|
||||
'notification_manager': types.SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
|
||||
'jsonify': lambda obj: obj}
|
||||
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', namespace=ns)
|
||||
response, status = handler()
|
||||
self.assertEqual((status, response['event_type']), (200, 'system_restore_completed'))
|
||||
event = events[0]
|
||||
self.assertEqual(event['severity'], 'WARNING' if warnings else 'INFO')
|
||||
result = self.render(event['event_type'], event['data'], 'en')
|
||||
self.assertIn('Post-restore tasks completed', result['body'])
|
||||
self.assertNotIn('fully ready', result['body'])
|
||||
if warnings: self.assertIn('missing modules', result['body'])
|
||||
|
||||
|
||||
def test_rich_backup_icon_tracks_outcome_and_digest_default_is_neutral(self):
|
||||
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
|
||||
def assign(name):
|
||||
return ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == name for t in n.targets)))
|
||||
icons = assign('EVENT_EMOJI')
|
||||
self.assertNotIn('✅', icons['backup_complete'])
|
||||
ns = {'TEMPLATES': assign('TEMPLATES'), 'EVENT_EMOJI': icons,
|
||||
'Dict': dict, 'Any': Any,
|
||||
'CATEGORY_EMOJI': assign('CATEGORY_EMOJI'), 'SEVERITY_ICONS': assign('SEVERITY_ICONS'),
|
||||
'FIELD_EMOJI': assign('FIELD_EMOJI'), '_localized_template_labels': lambda *a: {},
|
||||
'_lxc_update_label_icons': lambda *a: {}}
|
||||
enrich = extract(SCRIPTS / 'notification_templates.py', 'enrich_with_emojis', namespace=ns)
|
||||
for state, icon in [('confirmed', '💾✅'), ('unconfirmed', '💾❔'), ('failed', '💾❌')]:
|
||||
with self.subTest(state=state):
|
||||
title, body = enrich('backup_complete', 'node-a: Backup', 'Backup report',
|
||||
{'backup_outcome': state, '_notification_language': 'en'})
|
||||
self.assertTrue(title.startswith(icon), title)
|
||||
|
||||
def test_uncertain_outcomes_have_no_blanket_success_icon(self):
|
||||
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
|
||||
icon_map = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == 'EVENT_EMOJI' for t in n.targets)))
|
||||
self.assertNotIn('✅', icon_map['backup_complete']) # buffered digest has no outcome metadata
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Principal diagnostics must not be deduplicated against inventory substrings."""
|
||||
import unittest
|
||||
from notification_fixture import receive, LANGUAGES
|
||||
from notification_final_fixture import deliver
|
||||
|
||||
|
||||
def noisy_report(name='ordinary-web', storage='PBS', filename='null', cause='denied'):
|
||||
# Frozen native Perl notifier/log-reader shape; fixed-column table is
|
||||
# source-modeled from the official plaintext renderer, not a live PVE run.
|
||||
return ('\nDetails\n=======\nVMID Name Status Time Size Filename\n'
|
||||
f'100 {name} err 1m 1s 0 B {filename}\n\n'
|
||||
'Total running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\n'
|
||||
f'vzdump --all 1 --storage {storage} --mode snapshot\n\n' +
|
||||
'\n'.join(f'100: 2026-09-29 17:00:00 ERROR: earlier diagnostic {i}' for i in range(12)) +
|
||||
f'\n100: 2026-09-29 17:00:00 ERROR: {cause}\n\n')
|
||||
|
||||
|
||||
class PrincipalInventoryTests(unittest.TestCase):
|
||||
def assert_principal(self, raw, cause):
|
||||
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
|
||||
for language in LANGUAGES:
|
||||
for manual in (False, True):
|
||||
with self.subTest(language=language, manual=manual):
|
||||
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'},
|
||||
event.severity, language, manual=manual)
|
||||
exact = [line for line in result['body'].splitlines()
|
||||
if line.strip() == cause or line.rstrip().endswith('ERROR: ' + cause)]
|
||||
self.assertEqual(len(exact), 1)
|
||||
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
|
||||
self.assertLessEqual(len(diagnostics), 8)
|
||||
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
|
||||
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
|
||||
self.assertEqual(result['data']['pve_message'], raw)
|
||||
self.assertNotIn('raw-host', result['text'])
|
||||
self.assertIn('alias {rack.location}', result['text'])
|
||||
self.assertEqual(result['text'].count('ERROR: ' + cause), 1)
|
||||
|
||||
def test_late_principal_survives_incidental_guest_storage_and_archive(self):
|
||||
for name, storage, filename in (('ordinary-web','PBS','null'), ('denied','PBS','null'),
|
||||
('ordinary-web','denied','null'), ('ordinary-web','PBS','denied.tar')):
|
||||
with self.subTest(name=name, storage=storage, filename=filename):
|
||||
self.assert_principal(noisy_report(name,storage,filename), 'denied')
|
||||
|
||||
def test_raw_principal_braces_and_markup_survive_once(self):
|
||||
cause = 'denied {rack.location} <native>'
|
||||
self.assert_principal(noisy_report('denied', cause=cause), cause)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Frozen PVE 9.2 report through inert actual receiver and renderers.
|
||||
|
||||
No git history, host-management import, notification send or generated fixture.
|
||||
"""
|
||||
import ast
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / 'AppImage/scripts'
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import notification_templates as templates
|
||||
|
||||
PVE92 = '''Details
|
||||
=======
|
||||
VMID Name Status Time Size Filename
|
||||
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
|
||||
|
||||
Total running time: 1m 1s
|
||||
Total size: 1 GiB
|
||||
'''
|
||||
|
||||
|
||||
def receiver():
|
||||
tree = ast.parse((SCRIPTS / 'notification_events.py').read_text())
|
||||
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'ProxmoxHookWatcher')
|
||||
names = ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')
|
||||
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
|
||||
class Event:
|
||||
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
|
||||
ns['NotificationEvent'] = Event
|
||||
for name in names:
|
||||
node = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
node.decorator_list = []
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), '<inert-webhook>', 'exec'), ns)
|
||||
class Queue:
|
||||
def __init__(self): self.items = []
|
||||
def put(self, event): self.items.append(event)
|
||||
class Receiver:
|
||||
_hostname = 'node-a'
|
||||
_classify_pve = ns['_classify_pve']
|
||||
_map_severity = staticmethod(ns['_map_severity'])
|
||||
_backup_outcome = staticmethod(ns['_backup_outcome'])
|
||||
process_webhook = ns['process_webhook']
|
||||
def __init__(self): self._queue = Queue()
|
||||
return Receiver()
|
||||
|
||||
|
||||
def event_for(message, severity='info'):
|
||||
target = receiver()
|
||||
target.process_webhook({'fields': {'type': 'vzdump'}, 'severity': severity,
|
||||
'title': 'Backup', 'message': message})
|
||||
return target._queue.items[0]
|
||||
|
||||
|
||||
class PVE92Tests(unittest.TestCase):
|
||||
def test_exact_maintainer_report_is_confirmed(self):
|
||||
event = event_for(PVE92)
|
||||
self.assertEqual(event.data['backup_outcome'], 'confirmed')
|
||||
result = templates.render_template(event.event_type, event.data, 'en')
|
||||
self.assertIn('Backup complete', result['title'])
|
||||
self.assertIn('web (100)', result['title'])
|
||||
self.assertIn('✅', result['body'])
|
||||
|
||||
def test_failed_guest_identity_does_not_come_from_timestamp_or_ok_guest(self):
|
||||
failed = '101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z'
|
||||
ok = PVE92.splitlines()[3]
|
||||
for rows in (ok + '\n' + failed, failed + '\n' + ok):
|
||||
for severity in ('info', 'error'):
|
||||
with self.subTest(rows=rows, severity=severity):
|
||||
message = ('INFO: 100 01:01:06 OK\nINFO: Starting Backup of VM 100 (qemu)\n'
|
||||
+ PVE92.replace(ok, rows))
|
||||
event = event_for(message, severity)
|
||||
self.assertEqual(event.data['backup_outcome'], 'failed')
|
||||
self.assertEqual(event.data.get('vmname'), 'db')
|
||||
self.assertEqual(event.data.get('vmid'), '101')
|
||||
result = templates.render_template(event.event_type, event.data, 'en')
|
||||
self.assertIn('db (101)', result['title'])
|
||||
self.assertNotIn('web', result['title'])
|
||||
self.assertNotIn('01:01:06', result['title'])
|
||||
self.assertIn('❌ CT db (101)', result['body'])
|
||||
|
||||
def test_pbs_prefix_is_used_in_parsed_type_and_title(self):
|
||||
for prefix, kind, label in (('vm', 'qemu', 'VM'), ('ct', 'lxc', 'CT'),
|
||||
('other', '', 'VM/CT')):
|
||||
with self.subTest(prefix=prefix):
|
||||
message = PVE92.replace('vm/100/', prefix + '/100/')
|
||||
parsed = templates._parse_vzdump_message(message)
|
||||
self.assertEqual(parsed['vms'][0]['type'], kind)
|
||||
event = event_for(message)
|
||||
result = templates.render_template(event.event_type, event.data, 'en')
|
||||
self.assertIn(label + ' web (100)', result['title'])
|
||||
|
||||
def test_slovak_uses_normal_locale_resolution_without_source_sentence_overrides(self):
|
||||
from unittest.mock import patch
|
||||
data = {'hostname': 'node-a', 'category': 'temperature', 'entity_suffix': '',
|
||||
'reason': 'old', 'duration': '3d', 'original_severity': 'WARNING',
|
||||
'vmname': 'web', 'vmid': '100', 'storage': 'PBS', 'size': '1 GiB',
|
||||
'guests': 3, 'stubs': 0, 'stale_nodes': 0, 'components': 1,
|
||||
'warnings_block': ''}
|
||||
slovak = templates._load_runtime_catalog('sk')
|
||||
english = templates._load_runtime_catalog('en')
|
||||
for event, field in (('system_restore_completed', 'body'),):
|
||||
with self.subTest(event=event, field=field):
|
||||
value = slovak['templates'][event][field]
|
||||
result = templates.render_template(event, data, 'sk')
|
||||
self.assertEqual(result[field].strip(), value.format(**data).strip())
|
||||
self.assertNotIn(value, (SCRIPTS / 'notification_templates.py').read_text())
|
||||
# Independently updated and absent leaves use the usual provider.
|
||||
import copy
|
||||
future = copy.deepcopy(slovak)
|
||||
future['templates'][event][field] = 'REVIEWED {hostname}'
|
||||
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
|
||||
self.assertEqual(templates.render_template(event, data, 'sk')[field], 'REVIEWED node-a')
|
||||
future['templates'][event].pop(field)
|
||||
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
|
||||
self.assertEqual(templates.render_template(event, data, 'sk')[field],
|
||||
templates.render_template(event, data, 'en')[field])
|
||||
|
||||
def test_complete_table_beats_only_truncated_supplemental_log(self):
|
||||
message = ('INFO: Starting Backup of VM 100 (qemu)\n' + PVE92 +
|
||||
'\nLogs\n====\nINFO: Log output was too long to be displayed. Please see task log for details.')
|
||||
self.assertEqual(event_for(message).data['backup_outcome'], 'confirmed')
|
||||
for message, severity, expected in (
|
||||
(PVE92.replace('ok ', 'OK '), 'info', 'confirmed'),
|
||||
(message, 'warning', 'completed_with_warnings'),
|
||||
(message, 'error', 'failed'),
|
||||
(message + '\nERROR: archive write failed', 'info', 'failed'),
|
||||
(message + '\nWARNING: skipped file', 'info', 'completed_with_warnings'),
|
||||
(PVE92.replace('ok ', 'WARNINGS '), 'info', 'unconfirmed'),
|
||||
):
|
||||
with self.subTest(message=message, severity=severity):
|
||||
self.assertEqual(event_for(message, severity).data['backup_outcome'], expected)
|
||||
|
||||
def test_incomplete_and_unrelated_sections_cannot_certify_table(self):
|
||||
for message in (
|
||||
PVE92.split('Total running time:')[0],
|
||||
PVE92.replace('vm/100/2026-09-29T17:00:00Z', ''),
|
||||
PVE92.replace('\n\nTotal', '\n\nLogs\n======\nTotal'),
|
||||
PVE92.replace('\n\nTotal', '\n\nUnrelated section\n100 web ok\nTotal'),
|
||||
PVE92.replace('1m 1s 1 GiB', 'nonsense 1 GiB'),
|
||||
PVE92.replace('1 GiB vm/', 'garbage vm/'),
|
||||
PVE92.replace('1m 1s 1 GiB vm/', '1m 1s'),
|
||||
):
|
||||
with self.subTest(message=message):
|
||||
# Complete guest logs cannot rescue a genuinely incomplete table.
|
||||
message += '\nINFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'
|
||||
self.assertEqual(event_for(message).data['backup_outcome'], 'unconfirmed')
|
||||
|
||||
def test_blank_line_between_rows_does_not_hide_a_failure(self):
|
||||
message = PVE92.replace('\n\nTotal',
|
||||
'\n\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
|
||||
event = event_for(message)
|
||||
self.assertEqual(event.data['backup_outcome'], 'failed')
|
||||
result = templates.render_template(event.event_type, event.data, 'en')
|
||||
self.assertIn('db (101)', result['title'])
|
||||
self.assertIn('❌ CT db (101)', result['body'])
|
||||
|
||||
def test_changed_titles_and_rows_reach_actual_html_email_in_all_locales(self):
|
||||
import html
|
||||
from notification_channels import EmailChannel
|
||||
email = object.__new__(EmailChannel)
|
||||
email.subject_prefix = '[ProxMenux]'
|
||||
failed = PVE92.replace('\n\nTotal',
|
||||
'\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
|
||||
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
|
||||
for message, severity in ((PVE92, 'info'), (failed, 'info'), (failed, 'error')):
|
||||
with self.subTest(lang=lang, severity=severity, message=message):
|
||||
event = event_for(message, severity)
|
||||
result = templates.render_template(event.event_type, event.data, lang)
|
||||
context = {**event.data, '_event_type': event.event_type,
|
||||
'_notification_language': lang, '_group': result['group']}
|
||||
markup = html.unescape(email._format_html(result['title'], result['body'], event.severity, context))
|
||||
self.assertIn(result['title'], markup)
|
||||
if event.data['backup_outcome'] == 'failed':
|
||||
self.assertIn('db (101)', result['title'])
|
||||
self.assertNotIn('web', result['title'])
|
||||
self.assertIn('❌ CT db (101)', markup)
|
||||
status = templates.runtime_message('channels.email.status.failed', lang)
|
||||
else:
|
||||
self.assertIn('VM web (100)', result['title'])
|
||||
self.assertIn('✅ VM web (100)', markup)
|
||||
status = templates.runtime_message('channels.email.status.completed', lang)
|
||||
badge = (templates.runtime_message('channels.email.severity.critical', lang)
|
||||
if event.event_type == 'backup_fail' else status)
|
||||
self.assertIn('>' + badge.upper() + '</span>', markup)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Review regressions at actual consumer seams; no operational imports."""
|
||||
import unittest
|
||||
from notification_fixture import receive, LANGUAGES
|
||||
from notification_final_fixture import deliver
|
||||
|
||||
class ReviewCorrectionTests(unittest.TestCase):
|
||||
def test_subject_equivalent_late_error_is_reserved_before_cap(self):
|
||||
cause = 'job-end hook denied'
|
||||
# Frozen native Perl notifier/log-reader output; Rust table source-modeled.
|
||||
raw = '\nDetails\n=======\nVMID Name Status Time Size Filename \n100 web err 1m 1s 0 B null \n\nTotal running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\nvzdump --all 1 --storage PBS --mode snapshot\n\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 0\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 1\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 2\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 3\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 4\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 5\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 6\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 7\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 8\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 9\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 10\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 11\n100: 2026-09-29 17:00:00 ERROR: job-end hook denied\n\n\n'
|
||||
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
|
||||
for language in LANGUAGES:
|
||||
for manual in (False, True):
|
||||
with self.subTest(language=language, manual=manual):
|
||||
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'}, event.severity, language, manual=manual)
|
||||
self.assertEqual(result['text'].count(cause), 1)
|
||||
self.assertNotIn('raw-host', result['text'])
|
||||
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
|
||||
self.assertLessEqual(len(diagnostics), 8)
|
||||
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
|
||||
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
|
||||
self.assertEqual(result['data']['pve_message'], raw)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Human diagnostics translate; IDs, JSON, error types and forbidden effects do not."""
|
||||
from contextlib import ExitStack, nullcontext, redirect_stderr, redirect_stdout
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
import urllib.error
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_oci_complete_ui_sk as fixtures
|
||||
from proxmenux_oci import casaos, cli, converter, i18n
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
from proxmenux_oci.github_source import GitHubSource, SourceError
|
||||
import oci_image_cache
|
||||
import oci_dhcp_lease
|
||||
import oci_rocm_check
|
||||
import oci_ui
|
||||
import oci_watchdog
|
||||
import configure_jellyfin_encoding
|
||||
|
||||
|
||||
class TechnicalMessages(unittest.TestCase):
|
||||
setUp = fixtures.CompleteUI.setUp
|
||||
patch = fixtures.CompleteUI.patch
|
||||
|
||||
def remote_language(self):
|
||||
self.patch(oci_ui, '_language', 'sk')
|
||||
self.patch(oci_ui, '_cache', self.sk)
|
||||
|
||||
def test_real_readme_errors_translate_only_at_the_display_boundary(self):
|
||||
for text, expected in [('# App', 'README neobsahuje'),
|
||||
('## docker-compose\nplain text', 'Sekcia docker-compose neobsahuje blok kódu'),
|
||||
('## docker-compose\n```yaml\nservices: {}', 'Blok docker-compose nie je ukončený'),
|
||||
('## docker-compose\n```yaml\nnot-compose\n```', 'Nájdený blok nevyzerá')]:
|
||||
with self.subTest(text=text), self.assertRaises(converter.ConversionError) as caught:
|
||||
converter.extract_compose(text)
|
||||
raw = str(caught.exception)
|
||||
self.assertIn(expected, i18n.diagnostic(caught.exception))
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
|
||||
def test_schema_error_keeps_field_details_and_original_exception(self):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
self.catalog.validate({})
|
||||
raw = str(caught.exception)
|
||||
self.assertTrue(raw.startswith('La plantilla generada no cumple el esquema:'))
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Vygenerovaná šablóna nezodpovedá schéme:' + raw.split(':', 1)[1])
|
||||
|
||||
def test_missing_schema_validator_is_distinct_from_invalid_schema_without_effects(self):
|
||||
template = {}
|
||||
read = self.patch(Path, 'read_text', side_effect=AssertionError('schema must not be read without a validator'))
|
||||
with patch.dict(sys.modules, {'jsonschema': None}):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
self.catalog.validate(template)
|
||||
raw = 'python3-jsonschema is required to generate templates'
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Na generovanie šablón je potrebný python3-jsonschema')
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
self.assertIsNone(self.catalog.validate(template, required=False))
|
||||
read.assert_not_called()
|
||||
self.assertEqual(template, {})
|
||||
for effect in self.effects:
|
||||
effect.assert_not_called()
|
||||
|
||||
def test_real_casaos_error_preserves_the_protocol_and_localizes_the_message(self):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
casaos.parse_casaos_compose('[]')
|
||||
self.assertEqual(str(caught.exception), 'The CasaOS document is not a Compose mapping')
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Dokument Compose musí byť mapa kľúčov a hodnôt.')
|
||||
|
||||
def test_github_rate_limit_keeps_url_and_token_variable_without_a_network_call(self):
|
||||
url = 'https://fiction.invalid/catalogue?ref=example'
|
||||
error = urllib.error.HTTPError(url, 403, 'Forbidden', {'X-RateLimit-Remaining': '0'}, None)
|
||||
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen', side_effect=error)
|
||||
with self.assertRaises(SourceError) as caught:
|
||||
GitHubSource(token='fictional-token')._request(url)
|
||||
request.assert_called_once()
|
||||
text = i18n.diagnostic(caught.exception)
|
||||
self.assertIn('GitHub vrátil HTTP 403', text)
|
||||
self.assertIn(url, text)
|
||||
self.assertIn('GITHUB_TOKEN', text)
|
||||
self.assertNotIn('fictional-token', text)
|
||||
|
||||
def test_github_timeout_retains_external_reason_and_three_attempts(self):
|
||||
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen',
|
||||
side_effect=urllib.error.URLError('fictional external transport reason'))
|
||||
self.patch(__import__('time'), 'sleep')
|
||||
with self.assertRaises(SourceError) as caught:
|
||||
GitHubSource()._request('https://fiction.invalid/template')
|
||||
self.assertEqual(request.call_count, 3)
|
||||
self.assertIn('ani po 3 pokusoch: fictional external transport reason', i18n.diagnostic(caught.exception))
|
||||
|
||||
def test_unknown_diagnostics_and_machine_codes_pass_through_unchanged(self):
|
||||
for value in ['service:db:healthcheck-format', 'external tool: failure at 0x1234',
|
||||
'Check failed', 'Invalid cache', 'Ambiguous application', 'private-operation-id']:
|
||||
self.assertEqual(i18n.diagnostic(value), value)
|
||||
|
||||
def test_cli_help_preserves_flags_actions_defaults_and_numeric_types(self):
|
||||
argv = ['manage', '104', '--action', 'recreate', '--unattended', '--min-image-age-days', '7']
|
||||
slovak = cli.build_parser()
|
||||
self.assertIn('Vypísať aplikácie', slovak.format_help())
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
english = cli.build_parser()
|
||||
self.assertIn('List applications', english.format_help())
|
||||
self.assertEqual(vars(english.parse_args(argv)), vars(slovak.parse_args(argv)))
|
||||
|
||||
def test_cli_summary_preserves_image_reference_ports_mount_paths_and_status_code(self):
|
||||
template = self.catalog.compose('tandoor')
|
||||
saved = copy.deepcopy(template)
|
||||
text = cli._template_summary_text(template)
|
||||
self.assertIn('Obraz: ' + template['container_contract']['image']['reference'], text)
|
||||
self.assertIn('Dátové zväzky:', text)
|
||||
self.assertIn('Stav: ' + template['status'], text)
|
||||
for mount in template['container_contract']['volumes']:
|
||||
self.assertIn(mount['container_path'], text)
|
||||
self.assertEqual(template, saved)
|
||||
|
||||
def test_install_cli_no_stays_successful_cancellation_without_dispatch(self):
|
||||
template = self.catalog.compose('jellyfin')
|
||||
saved = copy.deepcopy(template)
|
||||
self.patch(cli.Catalog, 'compose', return_value=template)
|
||||
self.patch(cli, 'build_deployment', return_value={'fiction': True})
|
||||
self.patch(cli, '_deployment_summary_text', return_value='Fiktívny plán')
|
||||
answer = self.patch(__import__('builtins'), 'input', return_value='n')
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): self.assertEqual(cli.main(['install', 'jellyfin']), 0)
|
||||
self.assertIn('Nainštalovať? [y/N]:', answer.call_args.args[0])
|
||||
self.assertEqual(output.getvalue().count('Inštalácia bola zrušená.'), 1)
|
||||
self.assertEqual(template, saved)
|
||||
|
||||
def test_blocked_install_remains_error_one_and_never_asks_to_install(self):
|
||||
template = self.catalog.compose('jellyfin')
|
||||
template['compatibility'].update(automatic_install_candidate=False, untranslated_blockers=['fiction-blocker-code'])
|
||||
self.patch(cli.Catalog, 'compose', return_value=template)
|
||||
answer = self.patch(__import__('builtins'), 'input', side_effect=AssertionError('blocked installation asked a question'))
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(io.StringIO()), redirect_stderr(output):
|
||||
self.assertEqual(cli.main(['install', 'jellyfin']), 1)
|
||||
self.assertIn('Inštalácia je zablokovaná: fiction-blocker-code', output.getvalue())
|
||||
answer.assert_not_called()
|
||||
|
||||
def test_watchdog_manual_stop_stays_stopped_without_start_or_notice(self):
|
||||
self.remote_language()
|
||||
identity = '11111111-1111-4111-8111-111111111111'
|
||||
self.patch(oci_watchdog, 'watched', return_value={777: ('Fiction', identity)})
|
||||
self.patch(oci_watchdog.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_watchdog, 'same_installation', return_value=True)
|
||||
self.patch(oci_watchdog, 'is_running', return_value=False)
|
||||
self.patch(oci_watchdog, 'is_busy', return_value=False)
|
||||
self.patch(oci_watchdog, 'read_tasks', return_value=[('vzshutdown', '777', 75)])
|
||||
start = self.patch(oci_watchdog, 'start', side_effect=AssertionError('manual stop was undone'))
|
||||
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
|
||||
state = oci_watchdog.new_state(identity); state['seen'] = 50
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): oci_watchdog.look({777: state}, 100)
|
||||
self.assertTrue(state['settled'])
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
start.assert_not_called(); notice.assert_not_called()
|
||||
|
||||
def test_watchdog_localization_does_not_change_retry_state_or_notification_payload(self):
|
||||
identity = '11111111-1111-4111-8111-111111111111'
|
||||
self.patch(oci_watchdog, 'watched', return_value={777: ('Fiction', identity)})
|
||||
self.patch(oci_watchdog.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_watchdog, 'same_installation', return_value=True)
|
||||
self.patch(oci_watchdog, 'is_running', return_value=False)
|
||||
self.patch(oci_watchdog, 'is_busy', return_value=False)
|
||||
self.patch(oci_watchdog, 'read_tasks', return_value=[])
|
||||
start = self.patch(oci_watchdog, 'start', return_value=True)
|
||||
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
|
||||
outcomes = []
|
||||
for locale in ('en', 'sk'):
|
||||
with patch.object(oci_ui, '_language', locale), patch.object(oci_ui, '_cache', self.sk):
|
||||
state = oci_watchdog.new_state(identity); state['seen'] = 50
|
||||
text = io.StringIO()
|
||||
with redirect_stdout(text): oci_watchdog.look({777: state}, 100)
|
||||
outcomes.append((copy.deepcopy(state), notice.call_args, start.call_args))
|
||||
self.assertIn('CT 777 (Fiction)', text.getvalue())
|
||||
if locale == 'sk': self.assertIn('spúšťa sa znova (pokus 1)', text.getvalue())
|
||||
self.assertEqual(outcomes[0], outcomes[1])
|
||||
|
||||
def test_image_cache_json_contract_and_invalid_command_have_no_mutations(self):
|
||||
self.remote_language()
|
||||
self.patch(oci_image_cache.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_image_cache, 'archives_of', return_value={})
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): self.assertEqual(oci_image_cache.main(['list', '777']), 0)
|
||||
self.assertEqual(json.loads(output.getvalue()), {'archives': [], 'freed': 0})
|
||||
error = io.StringIO()
|
||||
with redirect_stderr(error): self.assertEqual(oci_image_cache.main(['invalid']), 2)
|
||||
self.assertIn('Použitie: oci_image_cache.py [prune | list VMID... | remove VMID...]', error.getvalue())
|
||||
|
||||
def test_rocm_timeout_localizes_but_external_gpu_errors_are_preserved(self):
|
||||
self.remote_language()
|
||||
self.patch(sys, 'argv', ['oci_rocm_check.py', '777'])
|
||||
for raw, expected in [('the GPU did not answer in time', 'GPU neodpovedalo včas'),
|
||||
('external provider exception 0x123', 'external provider exception 0x123')]:
|
||||
with self.subTest(raw=raw), patch.object(oci_rocm_check, 'check', side_effect=RuntimeError(raw)):
|
||||
output = io.StringIO()
|
||||
with redirect_stderr(output): self.assertEqual(oci_rocm_check.main(), 1)
|
||||
self.assertEqual(output.getvalue().strip(), expected)
|
||||
|
||||
def test_jellyfin_completion_distinguishes_changed_from_already_applied(self):
|
||||
self.remote_language()
|
||||
self.patch(sys, 'argv', ['configure_jellyfin_encoding.py', '/fiction-root', '{}'])
|
||||
for changed, expected in [(True, 'Nastavenia Jellyfin boli aktualizované:'),
|
||||
(False, 'Nastavenia Jellyfin už boli použité:')]:
|
||||
with self.subTest(changed=changed), patch.object(configure_jellyfin_encoding, 'update_encoding',
|
||||
return_value=(Path('/fiction-root/config/encoding.xml'), changed)):
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): configure_jellyfin_encoding.main()
|
||||
self.assertEqual(output.getvalue().strip(), expected + ' /config/encoding.xml')
|
||||
|
||||
def test_dhcp_completion_keeps_interface_and_ip_version_without_repairing_a_guest(self):
|
||||
self.remote_language()
|
||||
self.patch(oci_dhcp_lease, 'LOCKS', MagicMock())
|
||||
self.patch(oci_dhcp_lease.fcntl, 'flock')
|
||||
repair = self.patch(oci_dhcp_lease, 'repair', return_value=[('eth0', 4)])
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): oci_dhcp_lease.check(777, 0)
|
||||
repair.assert_called_once_with(777, 0)
|
||||
self.assertEqual(output.getvalue().strip(), 'CT 777: klient DHCP rozhrania eth0 (IPv4) bol znova spustený')
|
||||
|
||||
|
||||
class StandalonePublisher(unittest.TestCase):
|
||||
def test_mount_error_keeps_host_language_after_entering_the_container_root(self):
|
||||
spec = importlib.util.spec_from_file_location('fiction_namespace_publisher',
|
||||
fixtures.ROOT / 'oci/remote/rclone_mount_publish.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
inside = False
|
||||
def chroot(path):
|
||||
nonlocal inside
|
||||
inside = True
|
||||
def read(path, **kwargs):
|
||||
if inside:
|
||||
raise FileNotFoundError('Host language catalog is outside the container root')
|
||||
return ('{"language":"sk"}' if path.name == 'config.json' else
|
||||
'{"clone source mount tree":"naklonovať strom zdrojového pripojenia"}')
|
||||
libc = Mock(); libc.setns.return_value = 0; libc.syscall.return_value = -1
|
||||
with ExitStack() as guards:
|
||||
for target, name, options in [
|
||||
(sys, 'argv', {'new': ['publisher.py', '777', '/fiction-source', '/fiction-target', 'ro']}),
|
||||
(module.platform, 'machine', {'return_value': 'x86_64'}),
|
||||
(Path, 'read_text', {'autospec': True, 'side_effect': read}),
|
||||
(module.ctypes, 'CDLL', {'return_value': libc}),
|
||||
(module.ctypes, 'get_errno', {'return_value': 13}),
|
||||
(module.os, 'open', {'return_value': 42}),
|
||||
(module.os, 'close', {}), (module.os, 'fchdir', {}),
|
||||
(module.os, 'chroot', {'side_effect': chroot}), (module.os, 'chdir', {})]:
|
||||
guards.enter_context(patch.object(target, name, **options))
|
||||
with self.assertRaises(OSError) as caught:
|
||||
module.main()
|
||||
self.assertTrue(inside)
|
||||
self.assertEqual(caught.exception.errno, 13)
|
||||
self.assertIn('naklonovať strom zdrojového pripojenia', str(caught.exception))
|
||||
self.assertEqual(libc.syscall.call_args.args[0], module.SYSCALLS['x86_64'][0])
|
||||
|
||||
def test_copied_helper_has_localized_errors_without_engine_import_or_mount_syscalls(self):
|
||||
with tempfile.TemporaryDirectory(prefix='oci-publisher-language-') as directory:
|
||||
root = Path(directory); (root / 'lang').mkdir()
|
||||
(root / 'config.json').write_text('{"language":"sk"}')
|
||||
(root / 'lang/sk.json').write_text(json.dumps({'Usage:': 'Použitie:', 'Unsupported host architecture:': 'Nepodporovaná architektúra hostiteľa:'}))
|
||||
source = fixtures.ROOT / 'oci/remote/rclone_mount_publish.py'
|
||||
copied = root / 'publisher.py'; copied.write_bytes(source.read_bytes())
|
||||
spec = importlib.util.spec_from_file_location('fiction_standalone_publisher', copied)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {'oci_ui': None}), patch.dict(__import__('os').environ, {'PMX_BASE_DIR': str(root)}):
|
||||
spec.loader.exec_module(module)
|
||||
with patch.object(module.ctypes, 'CDLL', side_effect=AssertionError('mount syscall path entered')) as syscall:
|
||||
with patch.object(sys, 'argv', ['publisher.py']), redirect_stderr(io.StringIO()) as output:
|
||||
self.assertEqual(module.main(), 2)
|
||||
self.assertIn('Použitie: publisher.py PID SOURCE TARGET rw|ro', output.getvalue())
|
||||
with patch.object(sys, 'argv', ['publisher.py', '1', '/fiction-source', '/fiction-target', 'ro']), \
|
||||
patch.object(module.platform, 'machine', return_value='fiction-architecture'), redirect_stderr(io.StringIO()) as output:
|
||||
self.assertEqual(module.main(), 2)
|
||||
self.assertIn('Nepodporovaná architektúra hostiteľa: fiction-architecture', output.getvalue())
|
||||
syscall.assert_not_called()
|
||||
(root / 'config.json').write_text('invalid json')
|
||||
module._messages.cache_clear()
|
||||
self.assertEqual(module.translate('Usage:'), 'Usage:')
|
||||
(root / 'config.json').write_text('{"language":"en"}')
|
||||
module._messages.cache_clear()
|
||||
self.assertEqual(module.translate('Usage:'), 'Usage:')
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Real catalogue/wizard rendering, with host and installation effects forbidden."""
|
||||
import copy
|
||||
from contextlib import ExitStack, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(os.environ.get('OCI_I18N_TEST_ROOT', Path(__file__).resolve().parents[3]))
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote'), str(ROOT / 'oci/tests')]
|
||||
from proxmenux_oci import cli, i18n, installer, stack
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
from proxmenux_oci.converter import ConversionError
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
from test_advanced_flow_order import RecordingUI, addresses, storages
|
||||
|
||||
|
||||
class CompleteUI(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.scope = ExitStack()
|
||||
self.addCleanup(self.scope.close)
|
||||
self.sk = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
self.catalog = Catalog(ROOT / 'oci')
|
||||
self.patch(i18n, '_language', 'sk')
|
||||
self.patch(i18n, '_cache', self.sk)
|
||||
self.patch(installer.host, 'storages', side_effect=storages)
|
||||
self.patch(installer.host, 'bridges', return_value=[{'iface': 'vmbr0', 'cidr': '192.0.2.10/24'}])
|
||||
self.patch(installer.host, 'timezone', return_value='Europe/Bratislava')
|
||||
self.patch(installer.host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': False})
|
||||
self.patch(installer.access, 'ask_addresses', side_effect=addresses)
|
||||
self.patch(installer.access, 'ask_ipv4', return_value=('dhcp', None))
|
||||
self.effects = []
|
||||
for module, name in [(cli, '_install'), (cli, '_rclone_mount'), (cli, 'run_remote_install'),
|
||||
(installer, 'run_remote_install'), (cli.images, 'offer_removal'),
|
||||
(subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
|
||||
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink')]:
|
||||
self.effects.append(self.patch(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
|
||||
self.addCleanup(lambda: [effect.assert_not_called() for effect in self.effects])
|
||||
|
||||
def patch(self, obj, name, *args, **kwargs):
|
||||
return self.scope.enter_context(patch.object(obj, name, *args, **kwargs))
|
||||
|
||||
def test_search_no_match_uses_application_wording_and_never_installs(self):
|
||||
ui = Mock()
|
||||
ui.ask.return_value = 'fiction-no-such-app'
|
||||
cli._search(self.catalog, ui, [])
|
||||
self.assertEqual(ui.ask.call_args.args[0], 'Názov alebo časť popisu aplikácie')
|
||||
self.assertIn('Žiadna zhoda aplikácií', ui.message.call_args.args[0])
|
||||
ui.choose.assert_not_called()
|
||||
|
||||
def test_immich_detail_translates_service_roles_without_changing_template(self):
|
||||
template = self.catalog.compose('immich')
|
||||
before = copy.deepcopy(template)
|
||||
text = cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template)
|
||||
self.assertIn('Strojové učenie', text)
|
||||
self.assertIn('Aplikácia', text)
|
||||
self.assertIn('Oficiálny obraz', text)
|
||||
self.assertNotIn('Machine learning', text)
|
||||
self.assertEqual(template, before)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
self.assertIn('Machine learning', cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template))
|
||||
|
||||
def test_missing_catalogue_entry_preserves_raw_error_and_localizes_cli(self):
|
||||
for language, expected in [('sk', "Aplikácia 'fiction-missing' nie je v indexe"),
|
||||
('en', "The application 'fiction-missing' is not in the index")]:
|
||||
with self.subTest(language=language), patch.object(i18n, '_language', language):
|
||||
with self.assertRaisesRegex(ConversionError, "The application 'fiction-missing' is not in the index"):
|
||||
self.catalog.find_item('fiction-missing')
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(io.StringIO()), patch.object(sys, 'stderr', output), patch.object(cli, 'PROJECT_ROOT', ROOT / 'oci'):
|
||||
self.assertEqual(cli.main(['show', 'fiction-missing']), 1)
|
||||
self.assertEqual(output.getvalue().strip(), 'ERROR: ' + expected)
|
||||
|
||||
def test_missing_template_uses_existing_translation_and_keeps_identifier(self):
|
||||
self.patch(self.catalog, 'find_item', return_value={'id': 'fiction-missing'})
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
self.catalog.compose('fiction-missing')
|
||||
self.assertEqual(str(caught.exception), "No template is available for 'fiction-missing'")
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), "Pre aplikáciu 'fiction-missing' nie je dostupná šablóna")
|
||||
|
||||
def test_detail_cancel_and_escape_do_not_enter_install_or_rclone(self):
|
||||
for app in ('jellyfin', 'rclone', 'immich', 'suite-arr'):
|
||||
for escape in (False, True):
|
||||
with self.subTest(app=app, escape=escape):
|
||||
ui = Mock()
|
||||
ui.confirm.return_value = False
|
||||
ui.detail_menu.return_value = None
|
||||
if escape:
|
||||
ui.detail_menu.side_effect = UserCancelled()
|
||||
try:
|
||||
cli._app_detail(self.catalog, ui, self.catalog.find_item(app))
|
||||
except UserCancelled:
|
||||
self.assertTrue(escape)
|
||||
|
||||
def test_special_wizard_plans_are_identical_in_english_and_slovak(self):
|
||||
for app in ('immich', 'nextcloud-stack', 'paperless-ngx', 'tandoor'):
|
||||
for mode in (installer.DEFAULT_MODE, installer.ADVANCED_MODE):
|
||||
with self.subTest(app=app, mode=mode):
|
||||
template = self.catalog.compose(app)
|
||||
before = copy.deepcopy(template)
|
||||
plan_sk = installer.build_deployment(template, RecordingUI(), mode)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
plan_en = installer.build_deployment(template, RecordingUI(), mode)
|
||||
self.assertEqual(plan_sk, plan_en)
|
||||
self.assertEqual(template, before)
|
||||
text = cli._deployment_summary_text(template, plan_sk)
|
||||
self.assertNotIn('Consume/export:', text)
|
||||
if app == 'paperless-ngx':
|
||||
self.assertIn('Priečinky consume a export', text)
|
||||
|
||||
def test_romm_optional_credentials_are_localized_but_variable_names_stay(self):
|
||||
ui = RecordingUI()
|
||||
ui.confirm = lambda text, default=False, **kwargs: (ui.asked.append(text) or text.startswith('Nastaviť IGDB'))
|
||||
ui.password = lambda text, **kwargs: (ui.asked.append(text) or 'fiction-secret')
|
||||
ui.ask = lambda text, default=None, **kwargs: (ui.asked.append(text) or (default if default is not None else 'fiction-value'))
|
||||
plan = installer.build_deployment(self.catalog.compose('romm'), ui, installer.ADVANCED_MODE)
|
||||
self.assertTrue(any('Tajný kľúč klienta IGDB' == text for text in ui.asked))
|
||||
self.assertFalse(any('IGDB Client Secret' == text for text in ui.asked))
|
||||
server = next(service for service in plan['services'] if service['name'] == 'romm')
|
||||
self.assertTrue(any(row['name'] == 'IGDB_CLIENT_SECRET' for row in server['deployment']['environment']))
|
||||
|
||||
def test_tandoor_warning_keeps_negative_requirement_and_first_run_account(self):
|
||||
template = self.catalog.compose('tandoor')
|
||||
warning = i18n.translate(template['proxmox']['security_profile']['warning'])
|
||||
self.assertIn('nevyžaduje privilegovaný', warning)
|
||||
credentials = template['first_run']['credentials']
|
||||
self.assertTrue(any('Vygenerovaný účet správcu Tandoor' == i18n.translate(row['label']) for row in credentials))
|
||||
|
||||
def test_documented_commands_paths_and_literals_remain_copyable(self):
|
||||
fragments = {
|
||||
'rsnapshot starts with the default configuration, which backs up /data into /.snapshots. Edit /config/rsnapshot.conf inside the container to set your own backup points, snapshot root and retention intervals.': ['/data', '/.snapshots', '/config/rsnapshot.conf'],
|
||||
'Each /request command needs a backend: add a [[backends]] block in the same file with the url and api_key of your Sonarr, Radarr or Seerr instance, then restart the container.': ['/request', '[[backends]]', 'url', 'api_key'],
|
||||
'Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>': ['public: false', 'pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>'],
|
||||
'Certificate provider: empty for Let\'s Encrypt, zerossl for ZeroSSL': ["Let's Encrypt", 'zerossl', 'ZeroSSL'],
|
||||
'Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.': ['/data/media/movies', '/data/media/series', '/data/media/music'],
|
||||
}
|
||||
for source, tokens in fragments.items():
|
||||
with self.subTest(source=source):
|
||||
for token in tokens:
|
||||
self.assertIn(token, i18n.translate(source))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Exercise the native custom-image and registry UI with the real EN/SK lookup."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / 'oci/src'))
|
||||
from proxmenux_oci import custom, i18n, registry_accounts as accounts, registry_auth as auth
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
|
||||
COMPOSE = 'services:\n app:\n image: ghcr.io/owner/app:latest\n'
|
||||
|
||||
|
||||
class UI:
|
||||
def __init__(self, choices=(), answers=(), confirms=()):
|
||||
self.choices = iter(choices)
|
||||
self.answers = iter(answers)
|
||||
self.confirms = iter(confirms)
|
||||
self.seen = []
|
||||
|
||||
def choose(self, text, options, default=None, **kwargs):
|
||||
self.seen.append((text, options, kwargs))
|
||||
return next(self.choices)
|
||||
|
||||
def ask(self, text, default=None, **kwargs):
|
||||
self.seen.append(text)
|
||||
return next(self.answers)
|
||||
|
||||
def password(self, text):
|
||||
self.seen.append(text)
|
||||
raise UserCancelled(text)
|
||||
|
||||
def confirm(self, text, default=False, **kwargs):
|
||||
self.seen.append((text, default))
|
||||
return next(self.confirms)
|
||||
|
||||
def review(self, text, title=None, **kwargs):
|
||||
self.seen.append((text, title, kwargs))
|
||||
return False
|
||||
|
||||
def message(self, text, title=None):
|
||||
self.seen.append((text, title))
|
||||
|
||||
info = message
|
||||
|
||||
|
||||
class CustomRegistryLocalizationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
(self.base / 'lang').mkdir()
|
||||
(self.base / 'config.json').write_text('{"language":"sk"}')
|
||||
(self.base / 'lang/sk.json').write_bytes((ROOT / 'lang/sk.json').read_bytes())
|
||||
for name, value in [('BASE_DIR', self.base), ('_language', None), ('_cache', None)]:
|
||||
item = patch.object(i18n, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
# Any unmocked subprocess, network fetch or installation is a failure.
|
||||
self.run = self.guard(custom.subprocess, 'run')
|
||||
self.network = self.guard(custom.urllib.request, 'urlopen')
|
||||
self.install = self.guard(custom, 'install_template')
|
||||
|
||||
def guard(self, module, name):
|
||||
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
mock = item.start()
|
||||
self.addCleanup(item.stop)
|
||||
return mock
|
||||
|
||||
def no_effects(self):
|
||||
self.run.assert_not_called()
|
||||
self.network.assert_not_called()
|
||||
self.install.assert_not_called()
|
||||
|
||||
def manager(self):
|
||||
return SimpleNamespace(path=Path('/fixture/auth.json'),
|
||||
accounts=MagicMock(return_value=[{'registry': 'ghcr.io', 'account': 'owner',
|
||||
'method': 'Používateľské meno / token', 'status': 'Saved'}]),
|
||||
login=MagicMock(), logout=MagicMock())
|
||||
|
||||
def menu(self, ui, manager):
|
||||
with patch.object(accounts, 'require_root'):
|
||||
accounts.registry_accounts_menu(ui, auth=manager)
|
||||
|
||||
def test_dynamic_blockers_are_extracted_and_rendered_in_slovak(self):
|
||||
expected = {
|
||||
'multi-service-compose': 'definuje viac než jeden obraz',
|
||||
'native-multi-lxc-orchestrator-not-yet-implemented': 'definuje viac než jeden obraz',
|
||||
'top-level-configs': 'používa nastavenia Compose configs, ktoré tu nemajú ekvivalent',
|
||||
'devices-format': 'požadované zariadenia sú zadané v nepodporovanom formáte',
|
||||
'healthcheck-format': 'kontrola stavu je zadaná v nepodporovanom formáte',
|
||||
'stop-grace-period-format': 'časový limit zastavenia je zadaný v nepodporovanom formáte',
|
||||
'shm-size-format': 'veľkosť zdieľanej pamäte je zadaná v nepodporovanom formáte',
|
||||
'ulimits-format-or-resource': 'požadované limity systémových prostriedkov nemožno uplatniť',
|
||||
'mem-limit-format-or-below-proxmox-minimum': 'požadovaný limit pamäte nemožno uplatniť',
|
||||
'mem-limit-deploy-consistency-review': 'požaduje dva rozdielne limity pamäte',
|
||||
}
|
||||
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
extracted = extractor['extract_python_texts']([ROOT / 'oci/src'])
|
||||
self.assertTrue(set(custom.BLOCKER_TEXTS.values()) <= set(extracted))
|
||||
for code, message in expected.items():
|
||||
with self.subTest(code=code):
|
||||
self.assertEqual(custom.blocker_text('service:app:' + code), message)
|
||||
self.assertEqual(custom.blocker_text('unrecognized:raw-code'), 'unrecognized:raw-code')
|
||||
self.no_effects()
|
||||
|
||||
def test_cancelled_source_menu_is_translated_without_reading_an_image(self):
|
||||
ui = UI([None])
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
custom.explore(ui)
|
||||
self.assertEqual(str(caught.exception), 'Výber obrazu bol zrušený')
|
||||
self.assertIn('Obraz, ktorý nie je v katalógu', repr(ui.seen))
|
||||
self.assertIn('Len odkaz na obraz bez súboru Compose', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_invalid_compose_inputs_reach_localized_error_before_registry_or_install(self):
|
||||
cases = [
|
||||
('services: {}', 'Súbor Compose neobsahuje žiadnu službu'),
|
||||
('services: [', 'Súbor Compose nie je platný YAML:'),
|
||||
(COMPOSE + ' environment: 42\n', 'Položka environment'),
|
||||
(COMPOSE + ' volumes: {}\n', 'Položka volumes'),
|
||||
(COMPOSE + ' ports: invalid\n', 'Položka ports'),
|
||||
(COMPOSE + ' ports: [invalid]\n', 'Neplatný port'),
|
||||
(COMPOSE + ' volumes: [relative:relative]\n', 'Neplatná cesta dátového úložiska'),
|
||||
(COMPOSE + 'x-casaos:\n main: missing\n', 'Položka x-casaos.main'),
|
||||
]
|
||||
for text, expected in cases:
|
||||
with self.subTest(text=text), patch.object(custom, 'read_definition', return_value=(text, 'fixture')), \
|
||||
self.assertRaises(custom.ConversionError) as caught:
|
||||
custom.explore(UI())
|
||||
self.assertIn(expected, str(caught.exception))
|
||||
self.no_effects()
|
||||
|
||||
def test_converter_raw_contract_and_english_ui_are_preserved(self):
|
||||
bad = COMPOSE + ' ports: [invalid]\n'
|
||||
with self.assertRaises(custom.ConversionError) as raw:
|
||||
custom.template_from_compose(bad)
|
||||
self.assertEqual(str(raw.exception), "Puerto no valido: 'invalid'")
|
||||
with patch.object(i18n, '_language', 'en'), \
|
||||
patch.object(custom, 'read_definition', return_value=(bad, 'fixture')), \
|
||||
self.assertRaises(custom.ConversionError) as ui_error:
|
||||
custom.explore(UI())
|
||||
self.assertEqual(str(ui_error.exception), str(raw.exception))
|
||||
self.no_effects()
|
||||
|
||||
def test_conversion_message_retains_literal_diagnostic_data_and_unknown_fallback(self):
|
||||
detail = " {'target': '<path>{literal}\\á'}"
|
||||
message = custom.conversion_message(custom.ConversionError('Ruta de volumen no valida:' + detail))
|
||||
self.assertEqual(message, 'Neplatná cesta dátového úložiska v súbore Compose:' + detail)
|
||||
unknown = 'unknown <diagnostic> {literal}'
|
||||
self.assertEqual(custom.conversion_message(custom.ConversionError(unknown)), unknown)
|
||||
self.no_effects()
|
||||
|
||||
def test_all_legacy_converter_diagnostics_have_slovak_ui_and_unchanged_english(self):
|
||||
messages = [
|
||||
'environment must be a list or a mapping', 'volumes must be a list', 'ports must be a list',
|
||||
'The CasaOS Compose file has no services', 'The CasaOS document is not a Compose mapping',
|
||||
'The Compose file has no x-casaos metadata', 'x-casaos.main no identifica un servicio valido',
|
||||
'The main CasaOS service declares no image', 'Ruta de volumen no valida: /data',
|
||||
'Puerto no valido: invalid', 'Cannot normalise the CasaOS identifier: ???',
|
||||
'Docker Compose CasaOS no valido: fixture',
|
||||
]
|
||||
for text in messages:
|
||||
with self.subTest(text=text):
|
||||
self.assertNotEqual(custom.conversion_message(custom.ConversionError(text)), text)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
self.assertEqual(custom.conversion_message(custom.ConversionError(text)), text)
|
||||
self.no_effects()
|
||||
|
||||
def test_custom_review_cancel_keeps_installation_disabled(self):
|
||||
ui = UI()
|
||||
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'vloženého súboru Compose')), \
|
||||
patch.object(custom, 'registry_report', return_value=(True, 'Dostupný obraz')):
|
||||
custom.explore(ui)
|
||||
self.assertIn('Nainštalovať tento obraz?', repr(ui.seen))
|
||||
self.assertIn('Obraz:', repr(ui.seen))
|
||||
self.assertIn('ghcr.io/owner/app:latest', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_unavailable_image_explains_registry_accounts_without_authentication_or_install(self):
|
||||
ui = UI()
|
||||
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'fixture')), \
|
||||
patch.object(custom, 'registry_report', return_value=(False, 'Nedostupný obraz')), \
|
||||
patch.object(custom, '_registry_refused', return_value=False), \
|
||||
patch.object(accounts, 'offer_sign_in') as offer:
|
||||
custom.explore(ui)
|
||||
self.assertIn('prihláste cez Účty registrov', repr(ui.seen))
|
||||
offer.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_registry_metadata_dialog_uses_image_and_digest_and_retains_values(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['account-0', 'inspect', None], ['ghcr.io/owner/app:latest'])
|
||||
metadata = dict(architecture='amd64', version='<v>{literal}', size=1024, digest='sha256:' + 'a'*64)
|
||||
with patch.object(accounts, 'inspect_registry_image', return_value=metadata) as inspect:
|
||||
self.menu(ui, manager)
|
||||
inspect.assert_called_once_with('ghcr.io/owner/app:latest')
|
||||
rendered = repr(ui.seen)
|
||||
for value in ('Prístup k obrazu bol potvrdený.', 'Digest: sha256:', '<v>{literal}', 'Účty registrov'):
|
||||
self.assertIn(value, rendered)
|
||||
self.assertNotIn('obráz', rendered)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_credential_help_retains_permissions_and_no_encryption_claim(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['storage', None])
|
||||
self.menu(ui, manager)
|
||||
rendered = repr(ui.seen)
|
||||
for value in ('root', '700', '600', 'base64', 'nie sú šifrované', 'každý uzol klastra', '/fixture/auth.json'):
|
||||
self.assertIn(value, rendered)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_cancelled_password_and_declined_logout_do_not_change_accounts(self):
|
||||
for ui in (UI(['add', None], ['ghcr.io', 'owner']), UI(['account-0', 'logout', None], confirms=[False])):
|
||||
manager = self.manager()
|
||||
self.menu(ui, manager)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_mismatched_registry_is_rejected_before_download(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['account-0', 'download', None], ['quay.io/owner/app:latest'])
|
||||
with patch.object(accounts, 'download_registry_image') as download:
|
||||
self.menu(ui, manager)
|
||||
download.assert_not_called()
|
||||
self.assertIn('Zadajte obraz z tohto registra.', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_declined_private_image_signin_does_not_even_construct_auth_manager(self):
|
||||
ui = UI(confirms=[False])
|
||||
with patch.object(accounts, 'RegistryAuth') as manager, patch.object(accounts, 'require_root') as root:
|
||||
self.assertFalse(accounts.offer_sign_in(ui, 'ghcr.io/owner/app:latest'))
|
||||
manager.assert_not_called()
|
||||
root.assert_not_called()
|
||||
self.assertIn('Prihlásiť sa teraz?', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_registry_error_classes_hide_untrusted_remote_data_in_both_languages(self):
|
||||
cases = [('status code: 429', 'obmedzuje počet požiadaviek'),
|
||||
('requested access to the resource is denied', 'odmietol prístup'),
|
||||
('manifest unknown', 'Obraz alebo tag'),
|
||||
('unsupported manifest', 'metadáta obrazu'),
|
||||
('docker-credential-helper', 'správu prihlasovacích údajov'),
|
||||
('unknown failure', 'Obraz sa nepodarilo načítať')]
|
||||
for language in ('sk', 'en'):
|
||||
for error, expected in cases:
|
||||
with self.subTest(language=language, error=error), patch.object(i18n, '_language', language):
|
||||
message = auth.registry_error('registry.test:5000', error + ' REMOTE_PRIVATE_VALUE')
|
||||
self.assertNotIn('REMOTE_PRIVATE_VALUE', message)
|
||||
if language == 'sk':
|
||||
self.assertIn(expected, message)
|
||||
if 'credential' not in error:
|
||||
self.assertIn('registry.test:5000', message)
|
||||
self.no_effects()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Cancel a real editor through Menu/CLI/Monitor contracts, never apply it."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, management as menu, recreation
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
|
||||
|
||||
class ModifyCancellationTests(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.record.update({
|
||||
'installation_id': 'fixture-oci-edit-cancel',
|
||||
'observed': {'config_sha256': 'fixture-config-hash'},
|
||||
'template': {'id': 'fixture-app', 'catalog_ui': {'title': 'Fixture'},
|
||||
'container_contract': {'image': {'reference': 'example/fixture:latest'}}},
|
||||
'deployment': {'vmid': 110, 'hostname': 'fixture',
|
||||
'resources': {'cores': 2, 'memory_mb': 512},
|
||||
'rootfs': {'storage': 'fixture-storage', 'size_gb': 8},
|
||||
'mounts': [], 'devices': [], 'environment': [
|
||||
{'name': 'PASSWORD', 'value': 'private-fixture-value', 'sensitive': True}],
|
||||
'network': {'bridge': 'vmbr-fixture', 'ipv4': 'dhcp'}}})
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.ui.back_enabled = False
|
||||
self.path_write = self.guard(Path, 'write_text')
|
||||
self.path_bytes = self.guard(Path, 'write_bytes')
|
||||
|
||||
def script(self, choices, answers=(), confirms=(), reviews=()):
|
||||
self.ui.choose.side_effect = choices
|
||||
self.ui.ask.side_effect = answers
|
||||
self.ui.confirm.side_effect = confirms
|
||||
self.ui.review.side_effect = reviews
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
self.path_write.assert_not_called()
|
||||
self.path_bytes.assert_not_called()
|
||||
self.assertFalse(self.ui.back_enabled)
|
||||
|
||||
def test_main_cancel_is_distinct_falsy_cancellation(self):
|
||||
self.script([None])
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row, action='modify'), menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED)
|
||||
self.no_effects()
|
||||
|
||||
def test_main_discard_without_edits_returns_cancel_code(self):
|
||||
self.script(['discard'])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.ui.confirm.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_cancel_returns_code_six_without_extra_console_output(self):
|
||||
self.script([None])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_discard_after_edits_never_calls_backup_or_lifecycle(self):
|
||||
self.script(['cores', 'discard'], ['3'], [True])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
|
||||
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
|
||||
self.no_effects()
|
||||
|
||||
def test_main_cancel_after_edits_requires_discard_confirmation(self):
|
||||
self.script(['cores', None], ['3'], [True])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
|
||||
self.no_effects()
|
||||
|
||||
def test_declining_discard_preserves_draft_until_later_main_cancel(self):
|
||||
self.script(['cores', 'discard', None], ['3'], [False, True])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
last_options = dict(self.ui.choose.call_args.args[1])
|
||||
self.assertTrue(last_options['cores'].rstrip().endswith('3'))
|
||||
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
|
||||
self.assertEqual(self.ui.confirm.call_count, 2)
|
||||
self.no_effects()
|
||||
|
||||
def test_section_cancel_returns_to_editor_before_main_cancel(self):
|
||||
self.script(['cores', None], [UserCancelled()])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertEqual(self.ui.choose.call_count, 2)
|
||||
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
|
||||
self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_zero_cores_rejected_then_section_and_main_cancel_are_safe(self):
|
||||
self.script(['cores', None], ['0', UserCancelled()])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertIn('Cores: minimum 1', self.ui.message.call_args.args[0])
|
||||
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
|
||||
self.no_effects()
|
||||
|
||||
def test_review_no_returns_to_editor_with_draft_then_discard_cancels(self):
|
||||
self.script(['cores', 'apply', 'discard'], ['3'], [True], [False])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
|
||||
self.assertNotIn('private-fixture-value', self.ui.review.call_args.args[0])
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_list_after_editor_cancel(self):
|
||||
self.script(['110', 'modify', None, None])
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 4)
|
||||
self.no_effects()
|
||||
|
||||
def test_legacy_editor_callers_still_receive_none_on_cancel(self):
|
||||
for choice in (None, 'discard'):
|
||||
with self.subTest(choice=choice):
|
||||
self.script([choice])
|
||||
self.assertIsNone(recreation.edit_recreation(self.record, self.ui))
|
||||
self.no_effects()
|
||||
|
||||
def test_unchanged_review_is_not_mislabeled_as_user_cancellation(self):
|
||||
self.script(['apply'])
|
||||
self.assertIsNone(recreation.edit_recreation(self.record, self.ui, cancelled_result=menu.CANCELLED))
|
||||
self.ui.message.assert_called_once_with('Nothing was changed.', 'Modify OCI')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_editor_error_is_not_converted_to_cancellation(self):
|
||||
self.script(['cores'], [RuntimeError('fixture editor failure')])
|
||||
with self.assertRaisesRegex(RuntimeError, 'fixture editor failure'):
|
||||
menu.direct_management(ROOT/'oci', 110, 'modify')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_validation_failure_remains_failure_one(self):
|
||||
self.script(['cores'], [cli.InstallError('fixture validation failure')])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 1)
|
||||
self.no_effects()
|
||||
|
||||
def test_unattended_modify_is_rejected_before_opening_the_editor(self):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify', unattended=True), 1)
|
||||
self.ui.choose.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_accepted_proposal_keeps_original_backend_and_private_payload(self):
|
||||
self.script(['cores', 'apply'], ['3'], reviews=[True])
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
def lifecycle(command, title):
|
||||
self.assertEqual(command[:3], [sys.executable, str(ROOT/'oci/remote/oci_update_current.py'), '110'])
|
||||
proposal_file = Path(command[command.index('--proposal')+1])
|
||||
self.assertEqual(proposal_file.stat().st_mode & 0o777, 0o600)
|
||||
proposal = json.loads(proposal_file.read_text())
|
||||
self.assertEqual(proposal['operation'], 'recreate')
|
||||
self.assertEqual(proposal['candidate']['deployment']['resources']['cores'], 3)
|
||||
self.assertEqual(proposal['base_config_sha256'], 'fixture-config-hash')
|
||||
self.assertNotIn('private-fixture-value', ' '.join(command))
|
||||
return True
|
||||
self.lifecycle.side_effect = lifecycle
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 0)
|
||||
self.backup.assert_called_once()
|
||||
self.lifecycle.assert_called_once()
|
||||
self.assertEqual(self.record, self.original)
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_failed_backup_after_review_remains_failure_without_lifecycle(self):
|
||||
self.script(['cores', 'apply'], ['3'], reviews=[True])
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.write.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Keep the Monitor OCI editor path distinct from coordinated stack recreation."""
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
CLI = ROOT / 'oci/src/proxmenux_oci/cli.py'
|
||||
MONITOR = ROOT / 'AppImage/components/virtual-machines.tsx'
|
||||
WRAPPER = ROOT / 'scripts/oci/manage_instance.sh'
|
||||
EXTRA_DEVICES = ROOT / 'oci/src/proxmenux_oci/extra_devices.py'
|
||||
STACK_RECREATION = ROOT / 'oci/src/proxmenux_oci/stack_recreation.py'
|
||||
|
||||
|
||||
def extracted_stack_manager(scope):
|
||||
source = MENU.read_text(encoding='utf-8').replace(
|
||||
' from .stack_recreation import modify_stack\n', '')
|
||||
node = next(item for item in ast.parse(source).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == '_manage_stack')
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||
str(MENU), 'exec'), scope)
|
||||
return scope['_manage_stack']
|
||||
|
||||
|
||||
class MonitorModifyContractTests(TestCase):
|
||||
def test_monitor_sends_modify_for_every_instance(self):
|
||||
source = MONITOR.read_text(encoding='utf-8')
|
||||
self.assertIn('action: "update" | "modify" | "recover"', source)
|
||||
self.assertIn('action: "modify",', source)
|
||||
self.assertNotIn('"recreate"', source)
|
||||
|
||||
def test_the_editor_has_one_name_for_one_container_and_for_several(self):
|
||||
menu = MENU.read_text(encoding='utf-8')
|
||||
self.assertIn("('modify', translate('Modify: edit resources, network, paths and GPU'))", menu)
|
||||
self.assertIn("('modify', translate('Modify extra paths and devices'))", menu)
|
||||
self.assertEqual(menu.count("('recreate', translate("), 1)
|
||||
self.assertIn("('recreate', translate('Recreate every container with its saved configuration'))", menu)
|
||||
|
||||
def test_recreate_on_a_single_instance_changes_nothing(self):
|
||||
node = next(item for item in ast.parse(MENU.read_text(encoding='utf-8')).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'manage_instance')
|
||||
row = {'vmid': 101, 'reason': 'matched', 'stack': None, 'pending': False, 'status': 'installed'}
|
||||
run_lifecycle = Mock()
|
||||
scope = {'carry_records': lambda *_, **__: {}, 'check_selected': lambda _project, selected: selected,
|
||||
'translate': lambda text: text, '_run_lifecycle': run_lifecycle, '_manage_stack': Mock()}
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), str(MENU), 'exec'), scope)
|
||||
ui = Mock()
|
||||
self.assertFalse(scope['manage_instance'](Path('/inert'), ui, row, action='recreate'))
|
||||
self.assertEqual(ui.method_calls, [])
|
||||
run_lifecycle.assert_not_called()
|
||||
|
||||
def test_cli_and_monitor_wrapper_accept_the_modify_action(self):
|
||||
self.assertIn('choices=("update", "modify", "recreate")', CLI.read_text(encoding='utf-8'))
|
||||
self.assertIn('${ACTION:-} != "modify"', WRAPPER.read_text(encoding='utf-8'))
|
||||
|
||||
def test_modify_device_filter_matches_the_device_prompt_contract(self):
|
||||
caller = STACK_RECREATION.read_text(encoding='utf-8')
|
||||
device_prompt = EXTRA_DEVICES.read_text(encoding='utf-8')
|
||||
prompt = next(node for node in ast.parse(device_prompt).body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == 'ask_extra_devices')
|
||||
self.assertIn('kinds', [argument.arg for argument in prompt.args.args])
|
||||
self.assertIn("ask_extra_devices(ui, attached, True, kinds=", caller)
|
||||
|
||||
def test_modify_action_opens_the_stack_editor_without_running_lifecycle(self):
|
||||
primary = {'stack': {'members': [{'vmid': 101}]}}
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda *_: primary
|
||||
modify_stack = Mock(return_value=True)
|
||||
run_lifecycle = Mock()
|
||||
manager = extracted_stack_manager({
|
||||
'sys': SimpleNamespace(path=[]),
|
||||
'Path': Path,
|
||||
'modify_stack': modify_stack,
|
||||
'_run_lifecycle': run_lifecycle,
|
||||
})
|
||||
ui = SimpleNamespace()
|
||||
with patch.dict(sys.modules, {
|
||||
'oci_instances': instances,
|
||||
'oci_stack_replay': ModuleType('oci_stack_replay'),
|
||||
}):
|
||||
result = manager(Path('/inert'), ui, {'vmid': 101}, action='modify')
|
||||
|
||||
self.assertTrue(result)
|
||||
modify_stack.assert_called_once_with(Path('/inert'), ui, primary, run_lifecycle)
|
||||
run_lifecycle.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import unittest
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Every result the OCI engine reports has a notification the Monitor accepts and words."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
EVENTS = [f'oci_{kind}_{result}' for kind in ('update', 'modify', 'recreate') for result in ('completed', 'failed')]
|
||||
EVENTS += ['oci_watchdog_restarted', 'oci_watchdog_failed']
|
||||
|
||||
|
||||
class OciOperationEvents(TestCase):
|
||||
def test_the_monitor_accepts_and_words_every_event(self):
|
||||
route = (ROOT / 'AppImage/scripts/flask_notification_routes.py').read_text()
|
||||
accepted = route[route.index('_OCI_EVENTS = {'):route.index('@notification_bp.route(\'/api/internal/oci-event\'')]
|
||||
templates = (ROOT / 'AppImage/scripts/notification_templates.py').read_text()
|
||||
for event in EVENTS:
|
||||
self.assertIn(f"'{event}':", accepted, event)
|
||||
self.assertEqual(len(re.findall(rf"^ '{event}': \{{$", templates, re.MULTILINE)), 1, event)
|
||||
self.assertEqual(len(re.findall(rf"^ '{event}': +'\\[uU]", templates, re.MULTILINE)), 1, event)
|
||||
|
||||
def test_every_language_has_the_label_and_the_message_of_every_event(self):
|
||||
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
|
||||
for locale in LOCALES:
|
||||
catalog = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())
|
||||
for event in EVENTS:
|
||||
self.assertTrue(catalog['settings']['notifications']['eventTypes'][event].strip(), f'{locale} {event}')
|
||||
message = catalog['runtime']['notifications']['templates'][event]
|
||||
for part in ('title', 'body', 'label'):
|
||||
self.assertEqual(fields(message[part]), fields(english[event][part]), f'{locale} {event} {part}')
|
||||
self.assertEqual(message[part].count('\n'), english[event][part].count('\n'), f'{locale} {event} {part}')
|
||||
|
||||
def test_changing_an_application_is_reported_as_modified(self):
|
||||
single = (ROOT / 'oci/remote/oci_update_current.py').read_text()
|
||||
self.assertIn("kind = 'update' if operation == 'update' else 'modify'", single)
|
||||
self.assertIn('oci_operation_notice.operation([vmid], kind,', single)
|
||||
stack = (ROOT / 'oci/remote/oci_stack_modify.py').read_text()
|
||||
self.assertIn("oci_operation_notice.operation([vmid], 'modify',", stack)
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
|
||||
self.assertIn('new options', english['oci_modify_completed']['body'])
|
||||
self.assertNotIn('new options', english['oci_recreate_completed']['body'])
|
||||
@@ -0,0 +1,46 @@
|
||||
"""OCI manager Apps opens only on Proxmox VE 9.1 or later, the first release
|
||||
that runs OCI images as LXC containers."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LAUNCHER = ROOT / 'scripts/oci/oci_manager_apps.sh'
|
||||
|
||||
|
||||
class OciPveVersionGateTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
text = LAUNCHER.read_text(encoding='utf-8')
|
||||
cls.function = re.search(r'^pve_supports_oci\(\) \{\n.*?^\}\n', text, re.M | re.S).group(0)
|
||||
cls.text = text
|
||||
|
||||
def supported(self, version):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
fake = Path(folder, 'pveversion')
|
||||
# Without Proxmox the command is missing: a stand-in that fails hides the host's own.
|
||||
fake.write_text('#!/bin/sh\nexit 127\n' if version is None else
|
||||
f'#!/bin/sh\necho "pve-manager/{version}/b9984c6d90a4bd80 (running kernel: 6.17)"\n')
|
||||
fake.chmod(0o755)
|
||||
result = subprocess.run(['bash', '-c', f'PATH={folder}:/usr/bin:/bin\n{self.function}pve_supports_oci'],
|
||||
capture_output=True, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
def test_versions_from_9_1_open_it(self):
|
||||
for version in ('9.1.0', '9.1.4', '9.2.2', '10.0.1'):
|
||||
with self.subTest(version=version):
|
||||
self.assertTrue(self.supported(version))
|
||||
|
||||
def test_older_versions_and_hosts_without_proxmox_do_not(self):
|
||||
for version in ('8.4.1', '9.0.11', None):
|
||||
with self.subTest(version=version):
|
||||
self.assertFalse(self.supported(version))
|
||||
|
||||
def test_it_is_checked_before_anything_is_installed(self):
|
||||
self.assertLess(self.text.index('if ! pve_supports_oci; then'), self.text.index('ensure_oci_dependencies || exit 1'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Inert, extracted consumers for Rclone guidance and HedgeDoc metadata."""
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
INSTALLER = ROOT / 'oci/src/proxmenux_oci/installer.py'
|
||||
RCLONE = ('Configure the remote in the Rclone Web UI first. Checking its name may start a '
|
||||
'stopped CT, even in a dry run. Applying the mount restarts the CT and attempts '
|
||||
'to publish two FUSE views on the host; a dry run does not publish them.')
|
||||
|
||||
|
||||
def extract_function(path, name, **dependencies):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
scope = dict(dependencies)
|
||||
future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[future, node], type_ignores=[])),
|
||||
str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class RcloneHedgeDocWording(TestCase):
|
||||
def test_i18n_workflow_runs_for_remote_shell_edits(self):
|
||||
workflow = (ROOT / '.github/workflows/test-i18n.yml').read_text()
|
||||
self.assertEqual(workflow.count(" - 'oci/remote/**/*.sh'"), 2)
|
||||
|
||||
def test_remote_dry_run_messages_are_extracted_and_translate_with_fallback(self):
|
||||
import runpy
|
||||
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
shell = ROOT / 'oci/remote/configure_rclone_mount.sh'
|
||||
keys = [
|
||||
'Dry run completed; mount configuration was not applied. The CT was already running.',
|
||||
'Dry run completed; mount configuration was not applied. The CT was started for the remote check.',
|
||||
]
|
||||
extracted = extractor['extract_translate_texts'](ROOT / 'oci/remote')
|
||||
self.assertTrue(set(keys).issubset(extracted))
|
||||
ui = (ROOT / 'oci/remote/oci_ui.sh').read_text()
|
||||
lookup = 'translate() {' + ui.split('translate() {', 1)[1].split('\n}', 1)[0] + '\n}\n'
|
||||
gate = shell.read_text().split('if [[ $DRY_RUN == 1 ]]; then', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
|
||||
gate = 'if [[ $DRY_RUN == 1 ]]; then' + gate + '\nfi\n'
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cache = Path(directory) / 'locale.json'
|
||||
for status, expected_key in [('running', keys[0]), ('stopped', keys[1])]:
|
||||
for translations, expected in [({}, expected_key),
|
||||
({expected_key: 'MESSAGGIO DI TEST'}, 'MESSAGGIO DI TEST')]:
|
||||
with self.subTest(status=status, translations=translations):
|
||||
cache.write_text(json.dumps(translations))
|
||||
setup = (f'_OCI_LANGUAGE=it\n_OCI_LANG_FILE={cache}\n'
|
||||
f'STATUS={status}\nDRY_RUN=1\n' + lookup +
|
||||
'msg_ok() { printf "%s\\n" "$1"; }\n')
|
||||
result = subprocess.run(['bash', '-c', setup + gate],
|
||||
text=True, capture_output=True, timeout=5)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), expected)
|
||||
|
||||
def test_remote_dry_run_reports_stopped_ct_start_and_no_mount_publication(self):
|
||||
script = (ROOT / 'oci/remote/configure_rclone_mount.sh').read_text()
|
||||
seam = script.split('msg_info "$(translate "Checking the remote...")"', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
|
||||
# This is the real status/listremotes/dry-run gate, not the root-guarded script.
|
||||
seam = 'msg_info "$(translate "Checking the remote...")"' + seam + '\nfi\n'
|
||||
for status, dry_run, starts, post_gate in (
|
||||
('stopped', '1', 1, False), ('running', '1', 0, False),
|
||||
('stopped', '0', 1, True), ('running', '0', 0, True)):
|
||||
with self.subTest(status=status, dry_run=dry_run):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
trace = Path(directory) / 'pct.log'
|
||||
harness = '''
|
||||
set -Eeuo pipefail
|
||||
VMID=101 REMOTE_NAME=notes DRY_RUN=%s
|
||||
TRACE_FILE=%s
|
||||
pct() {
|
||||
printf 'pct:%%s\\n' "$*" >>"$TRACE_FILE"
|
||||
case "$1" in
|
||||
status) printf 'status: %s\\n' ;;
|
||||
exec) if [[ "${5:-}" == version ]]; then return 0; else printf 'notes:\\n'; fi ;;
|
||||
start) return 0 ;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
}
|
||||
oci_quiet() { "$@"; }
|
||||
oci_log() { :; }
|
||||
sleep() { :; }
|
||||
translate() { printf '%%s' "$1"; }
|
||||
msg_info() { printf 'info:%%s\\n' "$1"; }
|
||||
msg_ok() { printf 'ok:%%s\\n' "$1"; }
|
||||
die() { printf 'error:%%s\\n' "$1"; exit 1; }
|
||||
''' % (dry_run, str(trace), status)
|
||||
result = subprocess.run(['bash', '-c', harness + seam + '\nprintf "POST_DRY_GATE\\n"\n'],
|
||||
text=True, capture_output=True, timeout=5)
|
||||
calls = trace.read_text()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(calls.count('pct:start 101'), starts)
|
||||
self.assertIn('pct:exec 101 -- /usr/local/bin/rclone listremotes', calls)
|
||||
self.assertEqual('POST_DRY_GATE' in result.stdout, post_gate)
|
||||
if dry_run == '1':
|
||||
self.assertIn('mount configuration was not applied', result.stdout)
|
||||
if starts:
|
||||
self.assertIn('CT was started for the remote check', result.stdout)
|
||||
else:
|
||||
self.assertIn('CT was already running', result.stdout)
|
||||
self.assertNotIn('pct:stop', calls)
|
||||
|
||||
def test_hedgedoc_overlay_survives_index_enrichment(self):
|
||||
sys.path.insert(0, str(ROOT / 'oci/src'))
|
||||
try:
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
catalog = Catalog(root, source=Mock())
|
||||
catalog.apps_dir.mkdir(parents=True)
|
||||
catalog.overlays_dir.mkdir(parents=True)
|
||||
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
|
||||
upstream = 'HedgeDoc gives you access to all your files wherever you are.'
|
||||
corrected = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
|
||||
template['catalog_ui']['description']['en_US'] = upstream
|
||||
template['catalog_ui']['tagline']['en_US'] = upstream
|
||||
overlay = json.loads((ROOT / 'oci/catalog/overlays/hedgedoc.json').read_text())
|
||||
self.assertTrue(overlay['catalog_ui']['hidden'])
|
||||
self.assertEqual(overlay['catalog_ui']['description']['en_US'], corrected)
|
||||
self.assertEqual(overlay['catalog_ui']['tagline']['en_US'], corrected)
|
||||
(catalog.overlays_dir / 'hedgedoc.json').write_text(json.dumps(overlay))
|
||||
catalog._apply_overlay('hedgedoc', template)
|
||||
self.assertEqual(template['catalog_ui']['description']['en_US'], corrected)
|
||||
self.assertEqual(template['catalog_ui']['tagline']['en_US'], corrected)
|
||||
(catalog.apps_dir / 'hedgedoc.json').write_text(json.dumps(template))
|
||||
payload = {'applications': [{'id': 'hedgedoc', 'description': upstream},
|
||||
{'id': 'unmodified', 'description': 'Original summary'}]}
|
||||
catalog._enrich_index_from_templates(payload)
|
||||
self.assertEqual(payload['applications'][0]['description'], corrected)
|
||||
self.assertEqual(payload['applications'][1]['description'], 'Original summary')
|
||||
|
||||
def test_rclone_guidance_precedes_inputs_and_confirmation(self):
|
||||
events = []
|
||||
class UI:
|
||||
def message(self, text): events.append(('message', text))
|
||||
def ask(self, label, default=None, required=True):
|
||||
events.append(('ask', label))
|
||||
return {'VMID of the Rclone OCI container': '101',
|
||||
'Exact name of the remote': 'notes',
|
||||
'Mount name': 'notes'}.get(label, default or '')
|
||||
def choose(self, label, options, default):
|
||||
events.append(('choose', label))
|
||||
return default
|
||||
def confirm(self, label, default):
|
||||
events.append(('confirm', label))
|
||||
return True
|
||||
schema = {name: {'default': value} for name, value in (
|
||||
('vfs_cache_mode', 'writes'), ('shared_mount_root_parent', '/mnt/rclone'),
|
||||
('shared_mount_root', '/mnt/rclone/rw'),
|
||||
('shared_mount_read_only_root', '/mnt/rclone/ro'))}
|
||||
schema['vfs_cache_mode']['options'] = ['writes']
|
||||
template = {'proxmox': {'laboratory_contract': {'configuration_schema': schema}}}
|
||||
fn = extract_function(INSTALLER, 'build_rclone_mount_deployment',
|
||||
TerminalUI=UI, DialogUI=UI, translate=lambda text: text,
|
||||
re=re, InstallError=ValueError, UserCancelled=ValueError)
|
||||
deployment = fn(template, UI())
|
||||
self.assertEqual(events[0], ('message', RCLONE))
|
||||
self.assertEqual(events[1][0], 'ask')
|
||||
self.assertEqual(events[-1][0], 'confirm')
|
||||
self.assertEqual(deployment['remote_name'], 'notes')
|
||||
|
||||
def test_hedgedoc_detail_uses_corrected_template_and_index(self):
|
||||
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
|
||||
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
|
||||
item = next(item for item in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
|
||||
if item['id'] == 'hedgedoc')
|
||||
self.assertEqual(template['catalog_ui']['tagline']['en_US'], key)
|
||||
self.assertEqual(template['catalog_ui']['description']['en_US'], key)
|
||||
self.assertEqual(item['description'], key)
|
||||
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text',
|
||||
Any=object)
|
||||
fn = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
|
||||
source_text=source, translate=lambda text: text,
|
||||
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
|
||||
publisher=lambda _: 'LinuxServer.io',
|
||||
is_tested=lambda _: False,
|
||||
_display_architectures=lambda _: 'amd64',
|
||||
_image_label=lambda text: text,
|
||||
_recommended_storage=lambda _: 'Container volume')
|
||||
result = fn(SimpleNamespace(), item, template)
|
||||
self.assertIn(key, result)
|
||||
self.assertNotIn('gives you access to all your files', result)
|
||||
|
||||
def test_missing_empty_and_synthetic_translation_on_real_consumers(self):
|
||||
from unittest.mock import Mock
|
||||
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
|
||||
scope = {'language': lambda: 'it', '_cache': {}}
|
||||
lookup = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', **scope)
|
||||
cache = lookup.__globals__
|
||||
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
|
||||
item = next(x for x in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
|
||||
if x['id'] == 'hedgedoc')
|
||||
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text')
|
||||
detail = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
|
||||
source_text=source, translate=lookup,
|
||||
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
|
||||
publisher=lambda _: 'LinuxServer.io', is_tested=lambda _: False,
|
||||
_display_architectures=lambda _: 'amd64',
|
||||
_image_label=lambda text: text,
|
||||
_recommended_storage=lambda _: 'Container volume')
|
||||
for values, expected_rclone, expected_hedgedoc in (
|
||||
({}, RCLONE, key),
|
||||
({RCLONE: '', key: ''}, RCLONE, key),
|
||||
({RCLONE: 'GUIDA RCLONE', key: 'EDITOR COLLABORATIVO'},
|
||||
'GUIDA RCLONE', 'EDITOR COLLABORATIVO')):
|
||||
with self.subTest(values=values):
|
||||
cache['_cache'] = values
|
||||
ui = SimpleNamespace(message=Mock(), ask=Mock(side_effect=StopIteration))
|
||||
rclone = extract_function(INSTALLER, 'build_rclone_mount_deployment',
|
||||
TerminalUI=object, DialogUI=object, translate=lookup,
|
||||
re=re, InstallError=ValueError,
|
||||
UserCancelled=ValueError)
|
||||
# Stop at the first prompt: no operational path is entered.
|
||||
with self.assertRaises(StopIteration):
|
||||
rclone(json.loads((ROOT / 'oci/catalog/apps/rclone.json').read_text()), ui)
|
||||
ui.message.assert_called_once_with(expected_rclone)
|
||||
self.assertIn(expected_hedgedoc, detail(SimpleNamespace(), item, template))
|
||||
|
||||
def test_extractor_sees_both_new_sources(self):
|
||||
import runpy
|
||||
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
keys = extractor['extract_python_texts']([ROOT / 'oci/src'])
|
||||
self.assertIn(RCLONE, keys)
|
||||
keys = extractor['extract_catalog_texts']([ROOT / 'oci/catalog'])
|
||||
self.assertIn('HedgeDoc is a self-hosted collaborative Markdown editor for notes.', keys)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Offline checks of real OCI messages; never import administrative modules."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
REMOVE = ROOT / 'oci/remote/oci_remove.py'
|
||||
PREVIEW_HOST = 'Host paths found in container configs or saved records (not targeted for removal):'
|
||||
EMPTY_HOST = 'No host directories found in the available container configs or saved records.'
|
||||
POST_HOST = 'Host directory listed in saved records (not targeted for removal):'
|
||||
UPDATE = ('All {count} containers of the application are updated together (main CT: {vmid}). If there are new '
|
||||
'versions, all images are downloaded and verified, the application is stopped, each container is backed '
|
||||
'up and replaced with its new image. If the operation fails after a container was changed, the backups are '
|
||||
'restored. An operation that stops halfway is recovered from the OCI management menu.')
|
||||
PENDING = ('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack '
|
||||
'where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.')
|
||||
KEYS = (PREVIEW_HOST, EMPTY_HOST, POST_HOST, UPDATE, PENDING)
|
||||
TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
if name == 'remove':
|
||||
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
|
||||
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
|
||||
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class InventoryMessages(unittest.TestCase):
|
||||
def preview(self, configs, records, cache=None, translate=lambda text: text):
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
def read(root, vmid):
|
||||
if vmid not in records:
|
||||
raise OSError('unreadable record')
|
||||
return records[vmid]
|
||||
instances.read = read
|
||||
remover = ModuleType('oci_remove')
|
||||
remover.members_of = lambda root, vmid: (101, {}, [102, 101])
|
||||
remover.guest_config = lambda vmid: configs.get(vmid)
|
||||
remover.host_directories = lambda root, members: cache or []
|
||||
remover.granted_directories = lambda root, members: []
|
||||
remover.private_bridge = lambda primary: None
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
|
||||
'translate': translate, 're': re, 'subprocess': subprocess,
|
||||
'_selected_configuration': remover.guest_config}
|
||||
summary = extracted(MENU, '_removal_summary', scope)
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
return summary(Path('/inert'), 101)
|
||||
|
||||
def test_missing_metadata_is_not_reported_as_no_host_usage(self):
|
||||
text = self.preview({101: None, 102: None}, {})
|
||||
self.assertIn(EMPTY_HOST, text)
|
||||
self.assertNotIn('No host directory is used by this application.', text)
|
||||
|
||||
def test_discovered_paths_do_not_promise_exhaustive_preservation(self):
|
||||
text = self.preview({101: b'mp0: /bind/current,mp=/data\n', 102: None}, {}, ['/bind/saved'])
|
||||
self.assertIn(PREVIEW_HOST + '\n /bind/current\n /bind/saved', text)
|
||||
self.assertNotIn('Host directories that are kept, with their content:', text)
|
||||
|
||||
def test_removal_notice_qualifies_saved_record_source(self):
|
||||
# Extracted operational functions must never inspect the real pmxcfs.
|
||||
host = Path(self.enterContext(tempfile.TemporaryDirectory()))
|
||||
inert_path = lambda value: host / Path(value).name if str(value).startswith('/etc/pve/') else Path(value)
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
identity=lambda raw: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: b'description: owned',
|
||||
'host_directories': lambda root, members: ['/bind/saved'], 'granted_directories': lambda *args: [],
|
||||
'private_bridge': lambda primary: None,
|
||||
'remove_owned_host_firewall': lambda primary: True,
|
||||
'run': lambda *args: events.append(('run', args)),
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': inert_path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
|
||||
're': re, 'guest_node': lambda vmid: None,
|
||||
'remove_host_state': lambda vmid: events.append(('host', vmid)),
|
||||
'release_shared_host_files': lambda hookscripts: None,
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(('info', text)),
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
remove = extracted(REMOVE, 'remove', scope)
|
||||
remove(Path('/inert'), 101)
|
||||
self.assertIn(('info', POST_HOST + ' /bind/saved'), events)
|
||||
self.assertIn(('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1')), events)
|
||||
self.assertIn(('log', 101), events)
|
||||
self.assertIn(('host', 101), events)
|
||||
scope['translate'] = lambda text: 'Tradotto: ' + text if text == POST_HOST else text
|
||||
events.clear()
|
||||
remove(Path('/inert'), 101)
|
||||
self.assertIn(('info', 'Tradotto: ' + POST_HOST + ' /bind/saved'), events)
|
||||
|
||||
|
||||
class RecoveryMessages(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.journal = Path(self.tmp.name) / 'journal.json'
|
||||
self.members = [{'vmid': 101, 'deployment': {'mounts': []}}]
|
||||
self.primary = {'stack': {'members': self.members}}
|
||||
self.calls = []
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: self.primary
|
||||
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
|
||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
|
||||
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'_remove': lambda *args: self.fail('remove must not run'),
|
||||
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
|
||||
self.ui = SimpleNamespace(
|
||||
choose=lambda *args: 'update',
|
||||
review=lambda message, *args, **kwargs: self.calls.append(('review', message)) or False,
|
||||
confirm=lambda *args: self.fail('host confirmation must not run'),
|
||||
message=lambda *args: self.fail('unexpected error'))
|
||||
|
||||
def manage(self):
|
||||
with patch.dict(sys.modules, self.modules):
|
||||
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
|
||||
return next(message for kind, message in self.calls if kind == 'review')
|
||||
|
||||
def test_update_preview_names_the_stack_and_what_happens(self):
|
||||
message = self.manage()
|
||||
self.assertIn(UPDATE.format(count=len(self.members), vmid=101), message)
|
||||
|
||||
def test_pending_terminal_and_recovery_failed_states_share_bounded_wording(self):
|
||||
for phase in ('committed', 'rolled-back', 'recovery-failed'):
|
||||
with self.subTest(phase=phase):
|
||||
self.calls.clear()
|
||||
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
self.assertEqual(self.manage(), PENDING)
|
||||
self.assertFalse(any(isinstance(item[0], list) for item in self.calls))
|
||||
|
||||
def test_pending_confirmation_preserves_recover_command_in_each_state(self):
|
||||
self.ui.review = lambda message, *args, **kwargs: True
|
||||
for phase in ('committed', 'recovery-failed'):
|
||||
with self.subTest(phase=phase):
|
||||
self.calls.clear()
|
||||
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
with patch.dict(sys.modules, self.modules):
|
||||
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
|
||||
self.assertEqual(self.calls, [
|
||||
(['python3', '/inert/remote/oci_stack_native.py', '101', '--recover',
|
||||
'--acknowledge-external-data'], 'Recover OCI stack')])
|
||||
|
||||
def test_actual_transaction_distinguishes_terminal_cleanup_from_failed_restore(self):
|
||||
events = []
|
||||
scope = {'TERMINAL': {'committed', 'rolled-back'},
|
||||
'translate': lambda message: message,
|
||||
'member_tx': SimpleNamespace(log=lambda message: None),
|
||||
'write': lambda path, state: Path(path).write_text(json.dumps(state)),
|
||||
'msg_info': lambda message: None, 'msg_ok': lambda message: None,
|
||||
'member': lambda adapter, vmid: f'CT {vmid}'}
|
||||
extracted(TRANSACTION, 'save', scope)
|
||||
recover = extracted(TRANSACTION, 'recover_state', scope)
|
||||
class Adapter:
|
||||
def finalize(self, state): events.append('finalize')
|
||||
def validate(self, plan): events.append('validate')
|
||||
def restore(self, vmid, backup, txid):
|
||||
events.append('restore')
|
||||
raise RuntimeError('restore failed')
|
||||
def stop(self, vmid): events.append('stop')
|
||||
plan = {'start_order': [101], 'stop_order': [101]}
|
||||
terminal = {'phase': 'committed', 'plan': plan}
|
||||
self.assertIs(recover(self.journal, terminal, Adapter()), terminal)
|
||||
self.assertEqual(events, ['finalize'])
|
||||
events.clear()
|
||||
failed = dict(terminal, phase='recovery-failed', id='tx', running={'101': True},
|
||||
stop_intent=True, replacement_intent=True, backups={'101': {'archive': 'a'}})
|
||||
with self.assertRaisesRegex(RuntimeError, 'restore failed'):
|
||||
recover(self.journal, failed, Adapter())
|
||||
self.assertEqual(events, ['validate', 'stop', 'restore'])
|
||||
self.assertEqual(json.loads(self.journal.read_text())['phase'], 'recovery-failed')
|
||||
|
||||
def test_new_keys_extract_and_shipped_fallback_and_synthetic_translation(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(KEYS), len(set(KEYS)))
|
||||
self.assertTrue(set(KEYS) <= set(found), set(KEYS) - set(found))
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
|
||||
setattr(module, '_cache', {})
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
|
||||
module._language, module._cache = 'it', {key: 'Tradotto: ' + key for key in KEYS}
|
||||
self.assertEqual(module.translate(EMPTY_HOST), 'Tradotto: ' + EMPTY_HOST)
|
||||
preview = InventoryMessages().preview({101: None, 102: None}, {}, translate=module.translate)
|
||||
self.assertIn('Tradotto: ' + EMPTY_HOST, preview)
|
||||
preview = InventoryMessages().preview({101: b'mp0: /bind,mp=/data'}, {},
|
||||
translate=module.translate)
|
||||
self.assertIn('Tradotto: ' + PREVIEW_HOST + '\n /bind', preview)
|
||||
# Exercise the actual management consumer, not just the provider.
|
||||
self.scope['translate'] = module.translate
|
||||
self.assertIn(('Tradotto: ' + UPDATE).format(count=len(self.members), vmid=101), self.manage())
|
||||
self.journal.write_text(json.dumps({'phase': 'committed', 'plan': {'members': self.members}}))
|
||||
self.primary['pending_stack_transaction'] = str(self.journal)
|
||||
self.calls.clear()
|
||||
self.assertEqual(self.manage(), 'Tradotto: ' + PENDING)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Pending recovery through real Menu/CLI/Dialog paths; all host effects forbidden."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
import oci_native_modify as native_modify
|
||||
import oci_stack_transaction as stack_transaction
|
||||
import oci_ui
|
||||
|
||||
ACTUAL_RECOVER = transaction.recover
|
||||
|
||||
|
||||
class PendingRecoveryFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.record.update(pending_transaction='/fiction-only/transaction.json')
|
||||
self.row['pending'] = True
|
||||
self.ui.choose.return_value = 'recover'
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.writes = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
|
||||
self.processes = [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
|
||||
self.recovery = self.guard(transaction, 'recover')
|
||||
self.native_recovery = self.guard(native_modify, 'recover')
|
||||
self.stack_recovery = self.guard(stack_transaction, 'execute')
|
||||
|
||||
def stack(self):
|
||||
self.row['stack'] = True
|
||||
self.record['pending_stack_transaction'] = '/fiction-only/stack.json'
|
||||
self.original = copy.deepcopy(self.record)
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
for mock in [*self.writes, *self.processes, self.recovery, self.native_recovery, self.stack_recovery]:
|
||||
mock.assert_not_called()
|
||||
|
||||
|
||||
class PendingRecoveryTests(PendingRecoveryFixture):
|
||||
def test_single_no_is_distinct_falsy_cancellation(self):
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED)
|
||||
self.no_effects()
|
||||
|
||||
def test_single_menu_cancel_is_distinct_cancellation(self):
|
||||
self.ui.choose.return_value = None
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
|
||||
self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_single_no_and_cancel_return_six_without_duplicate_output(self):
|
||||
for choice in ('recover', None):
|
||||
with self.subTest(choice=choice), patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
self.ui.choose.return_value = choice
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_stack_no_returns_six_before_reading_the_journal(self):
|
||||
self.stack()
|
||||
with patch.object(cli, 'Catalog'), patch.object(Path, 'read_text', side_effect=AssertionError('Journal read after No')) as read:
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
|
||||
read.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_no_and_escape_cancel_single_and_stack(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for stacked in (False, True):
|
||||
if stacked:
|
||||
self.stack()
|
||||
for code in (1, 255):
|
||||
with self.subTest(stack=stacked, code=code):
|
||||
answers = ([] if stacked else [subprocess.CompletedProcess('dialog', 0, stderr='2')])
|
||||
answers.append(subprocess.CompletedProcess('dialog', code, stderr=''))
|
||||
with patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', side_effect=answers):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_cancel_and_escape_from_single_operation_menu(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for code in (1, 255):
|
||||
with self.subTest(code=code), patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_its_list_after_pending_single_or_stack_no(self):
|
||||
for stacked in (False, True):
|
||||
if stacked:
|
||||
self.stack()
|
||||
self.ui.choose.reset_mock()
|
||||
self.ui.choose.side_effect = ['110', None] if stacked else ['110', 'recover', None]
|
||||
with self.subTest(stack=stacked), patch.object(menu, '_clean_orphans'), \
|
||||
patch.object(menu, 'offer_recovery'), patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 2 if stacked else 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_confirmed_single_recovery_and_status_preserve_exact_commands_and_results(self):
|
||||
self.ui.review.return_value = True
|
||||
for action in ('recover', 'status'):
|
||||
for success, code in ((True, 0), (False, 1)):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = success
|
||||
self.ui.choose.return_value = action
|
||||
with self.subTest(action=action, result=success):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
|
||||
self.lifecycle.assert_called_once_with(
|
||||
[sys.executable, str(ROOT/'oci/remote/oci_instance_transaction.py'), action, '110'],
|
||||
'Recover OCI' if action == 'recover' else 'OCI management')
|
||||
self.write.assert_not_called(); self.backup.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_confirmed_stack_recovery_preserves_command_and_failure_is_not_cancel(self):
|
||||
self.stack()
|
||||
self.ui.review.return_value = True
|
||||
for success, code in ((True, 0), (False, 1)):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = success
|
||||
with self.subTest(result=success), patch.object(Path, 'read_text', return_value=json.dumps({'plan': {'members': [{'vmid': 110}, {'vmid': 111}]}})):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
|
||||
self.lifecycle.assert_called_once_with(
|
||||
[sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--recover', '--acknowledge-external-data'],
|
||||
'Recover OCI stack')
|
||||
self.write.assert_not_called(); self.backup.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_identity_failure_stays_failure_and_does_not_offer_recovery(self):
|
||||
with patch.object(menu, 'check_selected', return_value={**self.row, 'reason': 'different-installation'}):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 1)
|
||||
self.ui.choose.assert_not_called(); self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_unattended_does_not_turn_pending_menu_into_an_automatic_restore(self):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update', unattended=True), 1)
|
||||
self.no_effects()
|
||||
|
||||
def test_registration_cli_retains_its_separate_entry_point(self):
|
||||
with patch.object(cli, 'Catalog'), patch.object(menu, 'direct_recovery', return_value=0) as register, \
|
||||
patch.object(menu, 'direct_management', side_effect=AssertionError('Registration routed to rollback')):
|
||||
self.assertEqual(cli.main(['recover']), 0)
|
||||
register.assert_called_once_with(ROOT/'oci')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RecoverySlovakMeaning(PendingRecoveryFixture):
|
||||
def test_actual_recovery_dialogs_preserve_host_data_and_bounded_recovery_meaning(self):
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row)
|
||||
text = self.ui.review.call_args.args[0]
|
||||
self.assertIn('Zdieľané hostiteľské adresáre sa nevrátia späť', text)
|
||||
self.assertIn('po úspešnom vyčistení', text)
|
||||
self.assertEqual(self.ui.choose.call_args.args[0], 'Prerušená operácia')
|
||||
self.assertEqual(dict(self.ui.choose.call_args.args[1])['status'], 'Zobraziť stav')
|
||||
self.stack()
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row)
|
||||
self.assertEqual(self.ui.review.call_args.args[1], 'Obnoviť OCI aplikáciu s viacerými kontajnermi')
|
||||
self.assertIn('môžu zlyhať', self.ui.review.call_args.args[0])
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Obnoviť alebo dokončiť operáciu?')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RecoveryGuardMessages(PendingRecoveryFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for name, value in (('_language', 'sk'), ('_cache', cache)):
|
||||
p = patch.object(oci_ui, name, value)
|
||||
p.start(); self.addCleanup(p.stop)
|
||||
self.record.update(installation_id='fiction-installation', transaction_id='fiction-tx')
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.journal = Path(self.record['pending_transaction'])
|
||||
self.state = {'id': 'fiction-tx', 'vmid': 110, 'phase': 'backup-ready',
|
||||
'record': {'installation_id': 'fiction-installation'}, 'before_config': 'before',
|
||||
'backup': None, 'backup_sha256': 'saved-sha'}
|
||||
|
||||
def rejected(self, text, *, node='fiction-host', changed_backup=False):
|
||||
calls = []
|
||||
def readonly(*args):
|
||||
calls.append(args)
|
||||
if args[0] == 'pvesh':
|
||||
return json.dumps([{'type': 'lxc', 'vmid': 110, 'node': node}]).encode()
|
||||
if args == ('pct', 'config', '110'):
|
||||
return b'changed'
|
||||
raise AssertionError('Unexpected recovery effect: ' + repr(args))
|
||||
with patch.object(Path, 'read_text', return_value=json.dumps(self.state)), \
|
||||
patch.object(Path, 'exists', return_value=True), patch.object(Path, 'is_file', return_value=True), \
|
||||
patch.object(transaction.socket, 'gethostname', return_value='fiction-host'), \
|
||||
patch.object(transaction.host_mounts, 'verify_sources'), patch.object(transaction.gpu_devices, 'verify'), \
|
||||
patch.object(transaction, 'owned', return_value=b'fiction config'), \
|
||||
patch.object(transaction, 'filehash', return_value='changed-sha' if changed_backup else 'saved-sha'), \
|
||||
patch.object(transaction, 'run', side_effect=readonly), redirect_stdout(io.StringIO()):
|
||||
# Only reads before the rejecting guard are supplied. Any next
|
||||
# command or attempt to persist state fails this fixture.
|
||||
with self.assertRaisesRegex(ValueError, text):
|
||||
ACTUAL_RECOVER(ROOT/'oci', self.journal)
|
||||
self.no_effects()
|
||||
return calls
|
||||
|
||||
def test_foreign_transaction_is_rejected_before_probing_the_container(self):
|
||||
self.state['record']['installation_id'] = 'different-installation'
|
||||
self.assertEqual(self.rejected('Záznam nepatrí k tejto operácii'), [])
|
||||
|
||||
def test_missing_verified_backup_cannot_touch_a_changed_container(self):
|
||||
self.assertEqual(len(self.rejected('Chýba overená záloha; zmenený kontajner zostáva nedotknutý')), 2)
|
||||
|
||||
def test_changed_backup_is_rejected_before_decompression_or_restore(self):
|
||||
self.state['backup'] = '/fiction-only/backup.zst'
|
||||
self.assertEqual(len(self.rejected('Záloha bola zmenená; obnova zablokovaná', changed_backup=True)), 2)
|
||||
|
||||
def test_guest_on_another_node_is_never_overwritten(self):
|
||||
self.assertEqual(len(self.rejected('kontajner je na inom uzle; nie je prepísaný', node='another-node')), 1)
|
||||
|
||||
def test_incomplete_stack_recovery_state_fails_before_any_adapter_call(self):
|
||||
adapter = SimpleNamespace(validate=self.recovery, stop=self.native_recovery,
|
||||
restore=self.stack_recovery)
|
||||
state = {'phase': 'recovery-failed', 'plan': {'start_order': [110, 111]},
|
||||
'running': {'110': True}, 'stop_intent': True, 'replacement_intent': False}
|
||||
before = copy.deepcopy(state)
|
||||
with self.assertRaisesRegex(ValueError, 'Záznam operácie obsahuje neúplný stav obnovy'):
|
||||
stack_transaction.recover_state(self.journal, state, adapter)
|
||||
self.assertEqual(state, before)
|
||||
self.no_effects()
|
||||
|
||||
def test_critical_safety_and_untouched_result_have_the_correct_meaning(self):
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
self.assertEqual(cache['A host mount is not part of the journal; recovery blocked'],
|
||||
'Pripojenie adresára hostiteľa nie je uvedené v zázname operácie; obnova je zablokovaná')
|
||||
self.assertEqual(cache['Recovery completed. The container had not been modified yet.'],
|
||||
'Obnova dokončená. Kontajner ešte nebol zmenený.')
|
||||
self.assertEqual(cache['A backup was modified'], 'Záloha bola zmenená')
|
||||
self.assertEqual(cache['Recovering the previous installation'], 'Obnovovanie predchádzajúcej inštalácie')
|
||||
self.assertEqual(cache['Recover the previous installation'], 'Obnoviť predchádzajúcu inštaláciu')
|
||||
self.no_effects()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Restored-app registration through real Menu/CLI/Dialog; no host effects."""
|
||||
from contextlib import nullcontext, redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import string
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
|
||||
import oci_restore_recovery as restored
|
||||
import oci_ui
|
||||
|
||||
|
||||
class RegistrationFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.found = [{'vmid':110, 'hostname':'fiction-primary'}]
|
||||
self.original_found = copy.deepcopy(self.found)
|
||||
self.ui.confirm.return_value = False
|
||||
item = patch.object(menu, 'restored_applications', side_effect=lambda project:self.found)
|
||||
self.pending = item.start();self.addCleanup(item.stop)
|
||||
self.firewall = self.guard(menu, '_restored_firewall_rules')
|
||||
self.backend = [self.guard(restored, name) for name in ('recover','register','run')]
|
||||
self.files = [self.guard(Path, name) for name in ('write_text','write_bytes','unlink')]
|
||||
self.processes = [self.guard(subprocess, name) for name in ('run','Popen','check_output')]
|
||||
|
||||
def members(self):
|
||||
self.found.append({'vmid':111,'hostname':'fiction-dependent'})
|
||||
self.original_found = copy.deepcopy(self.found)
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
self.firewall.assert_not_called()
|
||||
self.safe_backend()
|
||||
|
||||
def safe_backend(self):
|
||||
for item in [*self.backend,*self.files,*self.processes]:item.assert_not_called()
|
||||
self.assertEqual(self.found,self.original_found)
|
||||
|
||||
def accepted(self, answers, completed=True):
|
||||
self.ui.confirm.side_effect=answers
|
||||
self.firewall.side_effect=None
|
||||
self.firewall.return_value=[{'vmid':row['vmid'],'port':61208 if row['vmid']==110 else 19999,
|
||||
'source':'192.0.2.0/24'} for row in self.found]
|
||||
self.lifecycle.side_effect=None;self.lifecycle.return_value=completed
|
||||
self.pending.side_effect=lambda project:[] if completed and self.lifecycle.called else self.found
|
||||
|
||||
|
||||
class RegistrationCancellationTests(RegistrationFixture):
|
||||
def test_offer_no_is_explicit_falsy_cancellation(self):
|
||||
self.assertIs(menu.offer_recovery(ROOT/'oci',self.ui),menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED);self.no_effects()
|
||||
|
||||
def test_direct_single_no_finishes_before_a_third_pending_read(self):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
self.assertEqual(self.pending.call_count,2)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_single_no_returns_six_without_output(self):
|
||||
with patch.object(cli,'Catalog'),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(cli.main(['recover']),6)
|
||||
self.assertEqual(output.getvalue(),'');self.no_effects()
|
||||
|
||||
def test_real_cli_multi_container_no_is_not_a_guest_action(self):
|
||||
self.members()
|
||||
with patch.object(cli,'Catalog'):
|
||||
self.assertEqual(cli.main(['recover']),6)
|
||||
text=self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('CT 110',text);self.assertIn('CT 111',text)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_initial_no_and_escape_for_single_and_multi(self):
|
||||
dialog=native_ui.DialogUI()
|
||||
for multi in (False,True):
|
||||
if multi:self.members()
|
||||
for code in (1,255):
|
||||
with self.subTest(multi=multi,code=code),patch.object(native_ui,'interactive_ui',return_value=dialog), \
|
||||
patch.object(dialog,'_run',return_value=subprocess.CompletedProcess('dialog',code,stderr='')):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_continues_to_list_after_registration_no(self):
|
||||
self.ui.choose.return_value=None
|
||||
with patch.object(menu,'_clean_orphans'),patch.object(menu,'_records_unread',return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci',self.ui)
|
||||
self.ui.choose.assert_called_once();self.no_effects()
|
||||
|
||||
def test_optional_firewall_and_start_no_registers_without_flags(self):
|
||||
self.members();self.accepted([True,False,False,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0],
|
||||
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
|
||||
self.assertEqual(self.ui.confirm.call_count,4);self.safe_backend()
|
||||
|
||||
def test_single_optional_no_preserves_registration_without_start(self):
|
||||
self.accepted([True,False,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0],
|
||||
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
|
||||
self.assertEqual(self.ui.confirm.call_count,3);self.safe_backend()
|
||||
|
||||
def test_only_one_confirmed_firewall_is_forwarded_and_no_start(self):
|
||||
self.members();self.accepted([True,False,True,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0][-2:],['--host-firewall','111'])
|
||||
self.assertNotIn('--start',self.lifecycle.call_args.args[0]);self.safe_backend()
|
||||
|
||||
def test_explicit_start_yes_retains_flag_but_never_starts_any_guest_here(self):
|
||||
self.accepted([True,False,True])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0][-1],'--start');self.safe_backend()
|
||||
|
||||
def test_failed_registration_stays_failure_with_pending_records(self):
|
||||
self.accepted([True,False,False],completed=False)
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
|
||||
self.safe_backend()
|
||||
|
||||
def test_empty_list_is_success_without_confirmation_or_backend(self):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.ui.confirm.assert_not_called();self.ui.message.assert_called_once();self.no_effects()
|
||||
|
||||
def test_root_identity_failure_never_probes_restored_apps(self):
|
||||
with patch.object(menu.os,'geteuid',return_value=1000):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
|
||||
self.pending.assert_not_called();self.ui.confirm.assert_not_called();self.no_effects()
|
||||
|
||||
def test_empty_offer_does_not_cancel_the_native_menu(self):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertIsNone(menu.offer_recovery(ROOT/'oci',self.ui))
|
||||
self.ui.confirm.assert_not_called();self.no_effects()
|
||||
|
||||
def test_empty_registration_state_is_rendered_in_slovak_without_recovery(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
with patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.ui.message.call_args.args[0],
|
||||
'Žiadna OCI aplikácia obnovená zo zálohy nečaká na opätovnú registráciu.')
|
||||
self.no_effects()
|
||||
|
||||
def test_initial_single_and_multi_dialog_describes_registration_precheck(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for multi in (False,True):
|
||||
if multi:self.members()
|
||||
with self.subTest(multi=multi),patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
text=self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('Pred opätovnou registráciou',text)
|
||||
self.assertNotIn('Obnova najprv',text)
|
||||
self.assertIn('aplikácie sa potom nespúšťajú automaticky',text)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_backend_blocker_stays_error_and_uses_registration_wording(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
plan={'title':'Fictional restored application','restored':[{'vmid':110}],
|
||||
'blockers':['Fictional missing resource'],'notes':[]}
|
||||
with patch.object(menu.sys,'argv',['fixture','recover','--root','/fiction-only']), \
|
||||
patch.object(restored.instances,'locked',return_value=nullcontext()), \
|
||||
patch.object(restored,'prepare',return_value=[plan]), \
|
||||
patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
|
||||
patch.object(restored,'msg_error') as error,patch.object(restored,'msg_info2'):
|
||||
self.assertEqual(restored.main(),1)
|
||||
error.assert_called_once_with('Túto aplikáciu zatiaľ nemožno znova zaregistrovať:')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_host_resource_check_reports_missing_data_without_creating_anything(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
plan={'restored':[{'vmid':110,'config':'mp0: /fiction-only/data,mp=/data\n',
|
||||
'copy':{'record':{'deployment':{},'observed':{}}}}], 'blockers':[],'notes':[]}
|
||||
with patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
|
||||
patch.object(restored.os.path,'isdir',return_value=False), \
|
||||
patch.object(restored,'firewall_rule',return_value=None):
|
||||
restored.check_host_resources(plan)
|
||||
self.assertEqual(len(plan['blockers']),1)
|
||||
self.assertIn('/fiction-only/data',plan['blockers'][0])
|
||||
self.assertIn('registráciu zopakujte',plan['blockers'][0])
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RegistrationContractTests(unittest.TestCase):
|
||||
def test_real_monitor_shell_recover_keeps_global_cli_contract_and_exit_six(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
base=Path(folder);(base/'scripts/oci').mkdir(parents=True)
|
||||
(base/'utils.sh').write_text('load_language() { :; }\ninitialize_cache() { :; }\n')
|
||||
(base/'scripts/oci/oci_manager_apps.sh').write_text('printf "%s\\n" "$@"\nexit 6\n')
|
||||
script=(ROOT/'scripts/oci/manage_instance.sh').read_text()
|
||||
script=script.replace('/usr/local/share/proxmenux',folder)
|
||||
import os
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,
|
||||
env={**os.environ,'ACTION':'recover','VMID':'not-a-vmid'})
|
||||
self.assertEqual(result.returncode,6);self.assertEqual(result.stdout,'recover\n')
|
||||
self.assertEqual(result.stderr,'')
|
||||
|
||||
def test_curated_slovak_registration_words_and_format_fields(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
expected={
|
||||
'Recover restored OCI applications':'Registrácia',
|
||||
'No restored OCI application is waiting to be recovered.':'registráciu',
|
||||
'Recover them now?':'zaregistrovať',
|
||||
'This application cannot be recovered yet:':'zaregistrovať',
|
||||
'The application could not be recovered:':'zaregistrovať',
|
||||
'Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.':'vyberte Nie',
|
||||
'it is locked or its disk could not be mounted':'uzamknutý',
|
||||
'The local storage does not accept snippets':'local',
|
||||
'it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.':'NVIDIA Container Toolkit',
|
||||
'the NVIDIA hook of the container is not available:':'spúšťací skript',
|
||||
'its Rclone mount is published again on this host at':'v ceste',
|
||||
'Choose another bridge for the container in Proxmox (Network) and run the recovery again.':'registráciu',
|
||||
'Mount or create it with its data and run the recovery again.':'registráciu',
|
||||
'Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.':'registráciu',
|
||||
}
|
||||
fields=lambda text:sorted(f for _,f,_,_ in string.Formatter().parse(text) if f)
|
||||
for key,word in expected.items():
|
||||
with self.subTest(key=key):
|
||||
self.assertIn(word,catalog[key]);self.assertEqual(fields(key),fields(catalog[key]))
|
||||
|
||||
def test_registration_web_text_is_distinct_from_previous_installation_recovery(self):
|
||||
for locale in ('en','sk'):
|
||||
data=json.loads((ROOT/'AppImage/messages'/locale/'common.json').read_text())['vmLxc']
|
||||
with self.subTest(locale=locale):
|
||||
self.assertEqual(data['ociUpdates']['registerRestored'],
|
||||
'Znova zaregistrovať' if locale=='sk' else 'Register again')
|
||||
self.assertEqual(data['ociUpdates']['recover'],'Obnoviť' if locale=='sk' else 'Recover')
|
||||
self.assertIn('zaregistrovať' if locale=='sk' else 'Register again',data['updates']['ociRestoredBody'])
|
||||
self.assertIn('Zaregistrovať' if locale=='sk' else 'Register',data['ociUpdates']['terminalTitleRecover'])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Exercise extracted removal/UI/guard consumers with inert dependencies only."""
|
||||
import ast
|
||||
import argparse
|
||||
from contextlib import nullcontext
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
REMOVE = ROOT / 'oci/remote/oci_remove.py'
|
||||
INSTALL = ROOT / 'oci/remote/install_oci.sh'
|
||||
PREVIEW = 'Private network targeted for release if no other guest uses it:'
|
||||
CONFIRM = 'Remove the application? Its container disks are deleted, and only a backup can bring them back.'
|
||||
RESULT = 'Removal command finished; review any warnings above.'
|
||||
BRIDGE = 'Private network of the application released:'
|
||||
BRIDGE_FAILURE = 'Could not complete private network release:'
|
||||
SUCCESS = 'The application was removed'
|
||||
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
|
||||
PORT = 'The host-monitor firewall port does not match exactly one TCP port in the container contract'
|
||||
OPTIONAL = 'A matching managed host firewall rule may also be removed.'
|
||||
MEMBERS = 'All of them are targeted for removal.'
|
||||
WHOLE = ('It cannot be removed on its own, because the application would stop '
|
||||
'working: continuing targets the whole application for removal.')
|
||||
TARGETS = 'Containers targeted for removal:'
|
||||
DATA = 'Container data targeted for deletion:'
|
||||
KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, PORT, OPTIONAL, MEMBERS, WHOLE, TARGETS, DATA)
|
||||
|
||||
|
||||
def extract(path, name, scope):
|
||||
if name == 'remove':
|
||||
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
|
||||
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
|
||||
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
|
||||
node = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class RemovalWordings(unittest.TestCase):
|
||||
def preview(self, bridge='vmbr9', translated=None, firewall=True):
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: {'installation_id': 'owned'}
|
||||
remover = ModuleType('oci_remove')
|
||||
remover.members_of = lambda root, vmid: (101, {'stack': {}, 'deployment':
|
||||
{'host_firewall': {'port': 8080}} if firewall else {}}, [102, 101])
|
||||
remover.guest_config = lambda member: None
|
||||
remover.host_directories = lambda root, members: []
|
||||
remover.granted_directories = lambda root, members: []
|
||||
remover.private_bridge = lambda primary: bridge
|
||||
remover.bridge_in_use = lambda bridge, removed: False
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: {}
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
||||
'translate': translated or (lambda value: value), 're': re,
|
||||
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
|
||||
|
||||
def test_preview_conditions_network_and_discloses_optional_firewall(self):
|
||||
preview = self.preview()
|
||||
self.assertIn(PREVIEW + ' vmbr9', preview)
|
||||
self.assertIn(OPTIONAL, preview)
|
||||
self.assertIn(MEMBERS, preview)
|
||||
self.assertIn(TARGETS, preview)
|
||||
self.assertIn(DATA, preview)
|
||||
self.assertNotIn('Private network of the application that is released:', preview)
|
||||
self.assertNotIn(PREVIEW, self.preview(bridge=None))
|
||||
self.assertNotIn(OPTIONAL, self.preview(firewall=False))
|
||||
|
||||
def test_member_preview_does_not_promise_whole_stack_removed(self):
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: {'installation_id': 'owned'}
|
||||
remover = ModuleType('oci_remove')
|
||||
remover.members_of = lambda root, vmid: (101, {'stack': {}}, [102, 101])
|
||||
remover.guest_config = lambda member: None
|
||||
remover.host_directories = lambda root, members: []
|
||||
remover.granted_directories = lambda root, members: []
|
||||
remover.private_bridge = lambda primary: None
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: {}
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
||||
'translate': lambda value: value, 're': re,
|
||||
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
|
||||
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
|
||||
self.assertIn(WHOLE, preview)
|
||||
|
||||
def test_cancellation_preserves_lifecycle_boundary_and_warns_precisely(self):
|
||||
calls = []
|
||||
ui = SimpleNamespace(review=lambda message, title, **kw: calls.append((message, kw)) or False,
|
||||
message=lambda *args: self.fail('unexpected error'))
|
||||
scope = {'_removal_summary': lambda project, vmid: 'Preview',
|
||||
'_run_lifecycle': lambda *args: self.fail('must not run'),
|
||||
'translate': lambda value: value, 'sys': SimpleNamespace(executable='python3'),
|
||||
'Path': Path, 'OSError': OSError, 'ValueError': ValueError, 'KeyError': KeyError}
|
||||
self.assertFalse(extract(MENU, '_remove', scope)(Path('/inert'), ui, 101))
|
||||
self.assertEqual(calls, [('Preview', {'question': CONFIRM, 'default': False})])
|
||||
|
||||
def test_shared_bridge_and_skipped_member_are_not_reported_removed(self):
|
||||
events = []
|
||||
records = {102: {'installation_id': 'owned'}, 101: {'installation_id': 'owned'}}
|
||||
scope = {'members_of': lambda root, vmid: (101, records[101], [102, 101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: records[vmid],
|
||||
identity=lambda raw: 'reassigned' if raw == b'reassigned' else 'owned',
|
||||
location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: b'reassigned' if vmid == 102 else b'owned',
|
||||
'host_directories': lambda root, members: [], 'granted_directories': lambda *args: [], 'private_bridge': lambda primary: 'vmbr9',
|
||||
'bridge_in_use': lambda bridge, removed: True,
|
||||
'release_bridge': lambda bridge: self.fail('shared bridge release'),
|
||||
'remove_owned_host_firewall': lambda record: None,
|
||||
'run': lambda *args: events.append(('run', args)),
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
|
||||
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(('info', text)),
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
remove = extract(REMOVE, 'remove', scope)
|
||||
for skipped_config in (b'reassigned', None):
|
||||
with self.subTest(skipped_config=skipped_config):
|
||||
scope['guest_config'] = lambda vmid: skipped_config if vmid == 102 else b'owned'
|
||||
events.clear()
|
||||
remove(Path('/inert'), 101)
|
||||
self.assertEqual([x for x in events if x[0] == 'run'],
|
||||
[('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1'))])
|
||||
self.assertFalse(any(x == ('ok', 'The application was removed') for x in events))
|
||||
self.assertTrue(any('still used' in x[1] for x in events if x[0] == 'info'))
|
||||
self.assertTrue(any('no longer exists' in x[1] if skipped_config is None
|
||||
else 'belongs to another container' in x[1]
|
||||
for x in events if x[0] == 'warn'))
|
||||
# The actual main() success line is independently guarded by the expected neutral literal.
|
||||
tree = ast.parse(REMOVE.read_text())
|
||||
main = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'main')
|
||||
self.assertIn(RESULT, [n.value for n in ast.walk(main) if isinstance(n, ast.Constant) and isinstance(n.value, str)])
|
||||
|
||||
def test_actual_main_success_is_completion_not_all_members_removed(self):
|
||||
events = self.lifecycle_events(missing=True)
|
||||
self.assertIn(('warn', 'The container no longer exists: CT 102'), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def lifecycle_events(self, *, missing=False, reassigned=False, bridge='none',
|
||||
ip_rc=0, pvesh_rc=0, ip_error=False, firewall_failure=False,
|
||||
firewall_success=False, kept=False):
|
||||
events, commands = [], []
|
||||
self.last_commands = commands
|
||||
record = {'installation_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}
|
||||
if firewall_failure or firewall_success:
|
||||
record['deployment'] = {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}
|
||||
records = {101: record, 102: {'installation_id': 'owned'}}
|
||||
|
||||
def fake_command(args, **kwargs):
|
||||
commands.append(tuple(args))
|
||||
if ip_error and args[:2] == ['ip', 'link']:
|
||||
raise OSError('ip unavailable')
|
||||
if args[:2] == ['pvesh', 'get']:
|
||||
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + record['installation_id'],
|
||||
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
|
||||
'action': 'ACCEPT', 'pos': 2}]), returncode=0)
|
||||
if args[:2] == ['pvesh', 'delete'] and '/firewall/' in args[2] and firewall_failure:
|
||||
raise subprocess.CalledProcessError(1, args)
|
||||
return SimpleNamespace(returncode=ip_rc if args[:2] == ['ip', 'link'] else
|
||||
pvesh_rc if args[:2] == ['pvesh', 'delete'] else 0)
|
||||
|
||||
fake_subprocess = SimpleNamespace(run=fake_command, CalledProcessError=subprocess.CalledProcessError)
|
||||
scope = {'members_of': lambda root, vmid: (101, record, [102, 101]),
|
||||
'instances': SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
|
||||
read=lambda root, vmid: records[vmid],
|
||||
identity=lambda cfg: record['installation_id'] if cfg == b'primary' else
|
||||
'other' if cfg == b'reassigned' else 'owned',
|
||||
location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: (None if missing else b'reassigned' if reassigned else b'owned')
|
||||
if vmid == 102 else b'primary',
|
||||
'host_directories': lambda *args: ['/retained'] if kept else [], 'granted_directories': lambda *args: [],
|
||||
'private_bridge': lambda primary: 'vmbr9' if bridge != 'none' else None,
|
||||
'bridge_in_use': lambda *args: bridge == 'shared',
|
||||
'run': lambda *args: commands.append(args), 'subprocess': fake_subprocess,
|
||||
'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json, 're': re,
|
||||
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
|
||||
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None,
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(('info', text)),
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text)),
|
||||
'msg_error': lambda text: events.append(('error', text)),
|
||||
'argparse': argparse, 'os': SimpleNamespace(geteuid=lambda: 0),
|
||||
'sys': SimpleNamespace(argv=['oci_remove.py', '101'])}
|
||||
scope['release_bridge'] = extract(REMOVE, 'release_bridge', scope)
|
||||
scope['remove_owned_host_firewall'] = extract(REMOVE, 'remove_owned_host_firewall', scope)
|
||||
scope['remove'] = extract(REMOVE, 'remove', scope)
|
||||
with patch.object(sys, 'argv', ['oci_remove.py', '101']):
|
||||
self.assertEqual(extract(REMOVE, 'main', scope)(), 0)
|
||||
return events
|
||||
|
||||
def test_clean_removal_has_clear_success(self):
|
||||
events = self.lifecycle_events()
|
||||
self.assertEqual(events[-1], ('ok', 'The application was removed'))
|
||||
self.assertFalse(any(kind == 'warn' for kind, _ in events))
|
||||
|
||||
def test_skipped_identity_and_kept_bridge_have_partial_result(self):
|
||||
events = self.lifecycle_events(reassigned=True, bridge='shared')
|
||||
self.assertIn(('warn', 'The VMID belongs to another container now and is not touched: CT 102'), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_shared_bridge_intentionally_kept_does_not_taint_clean_removal(self):
|
||||
events = self.lifecycle_events(bridge='shared')
|
||||
self.assertTrue(any('still used' in text for kind, text in events if kind == 'info'))
|
||||
self.assertEqual(events[-1], ('ok', 'The application was removed'))
|
||||
|
||||
def test_failed_bridge_commands_have_partial_result(self):
|
||||
for ip_rc, pvesh_rc in ((1, 0), (0, 1), (1, 1)):
|
||||
with self.subTest(ip_rc=ip_rc, pvesh_rc=pvesh_rc):
|
||||
events = self.lifecycle_events(bridge='private', ip_rc=ip_rc, pvesh_rc=pvesh_rc)
|
||||
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
|
||||
for kind, text in events), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_successful_bridge_release_and_retained_paths_are_clean(self):
|
||||
events = self.lifecycle_events(bridge='private', kept=True)
|
||||
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||
self.assertTrue(any(kind == 'info' and '/retained' in text for kind, text in events))
|
||||
self.assertFalse(any(kind == 'warn' for kind, _ in events))
|
||||
self.assertEqual(events[-1], ('ok', SUCCESS))
|
||||
|
||||
def test_bridge_exception_is_reported_but_cleanup_continues(self):
|
||||
events = self.lifecycle_events(bridge='private', ip_error=True)
|
||||
self.assertEqual([cmd[:2] for cmd in self.last_commands],
|
||||
[('pct', 'stop'), ('pct', 'destroy'), ('pct', 'stop'),
|
||||
('pct', 'destroy'), ('ip', 'link'), ('pvesh', 'delete')])
|
||||
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
|
||||
for kind, text in events), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_successful_firewall_delete_is_clean(self):
|
||||
events = self.lifecycle_events(firewall_success=True)
|
||||
self.assertTrue(any('Host firewall rule removed:' in text for kind, text in events if kind == 'ok'))
|
||||
self.assertEqual(events[-1], ('ok', SUCCESS))
|
||||
|
||||
def test_firewall_delete_failure_has_partial_result(self):
|
||||
events = self.lifecycle_events(firewall_failure=True)
|
||||
self.assertIn(('warn', FIREWALL), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_a_failed_bridge_release_warns_instead_of_claiming_it(self):
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
identity=lambda cfg: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
|
||||
'guest_config': lambda vmid: b'owned', 'host_directories': lambda *args: [], 'granted_directories': lambda *args: [],
|
||||
'private_bridge': lambda primary: 'vmbr9', 'bridge_in_use': lambda *args: False,
|
||||
'release_bridge': lambda bridge: events.append(('attempt', bridge)),
|
||||
'remove_owned_host_firewall': lambda record: None, 'run': lambda *args: None,
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
|
||||
'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
|
||||
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
|
||||
'translate': lambda text: text, 'msg_info': lambda text: None,
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
|
||||
self.assertIn(('attempt', 'vmbr9'), events)
|
||||
self.assertIn(('warn', BRIDGE_FAILURE + ' vmbr9'), events)
|
||||
self.assertNotIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||
|
||||
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
|
||||
events = []
|
||||
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
|
||||
'subprocess': subprocess, 'translate': lambda text: text,
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
|
||||
{'installation_id': 'invalid', 'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(events, [('warn', FIREWALL)])
|
||||
|
||||
def test_ambiguous_firewall_match_warns_without_deleting_other_rules(self):
|
||||
events, commands = [], []
|
||||
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
rule = {'comment': 'ProxMenux OCI firewall ' + installation_id, 'dport': '8080',
|
||||
'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in', 'action': 'ACCEPT', 'pos': 2}
|
||||
def fake_run(args, **kwargs):
|
||||
commands.append(args)
|
||||
return SimpleNamespace(stdout=json.dumps([rule, {**rule, 'pos': 3}]))
|
||||
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
|
||||
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
|
||||
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
|
||||
{'installation_id': installation_id,
|
||||
'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(events, [('warn', FIREWALL)])
|
||||
self.assertEqual([command[1] for command in commands], ['get'])
|
||||
|
||||
def test_firewall_delete_exception_has_unknown_outcome_not_unchanged_rule(self):
|
||||
events = []
|
||||
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
commands = []
|
||||
def fake_run(args, **kwargs):
|
||||
commands.append(args)
|
||||
if args[1] == 'get':
|
||||
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + installation_id,
|
||||
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
|
||||
'action': 'ACCEPT', 'pos': 2}]))
|
||||
raise subprocess.CalledProcessError(1, args)
|
||||
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
|
||||
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
|
||||
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text)), 'OSError': OSError, 'ValueError': ValueError}
|
||||
extract(REMOVE, 'remove_owned_host_firewall', scope)({'installation_id': installation_id,
|
||||
'deployment': {'host_firewall': {'port': 8080, 'source': '192.0.2.0/24'}}})
|
||||
self.assertEqual(commands[-1][1], 'delete')
|
||||
self.assertEqual(events, [('warn', FIREWALL)])
|
||||
|
||||
def test_extractor_and_runtime_lookup_fallback_and_synthetic_translation(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
found = set(generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote']))
|
||||
shell_keys = set(generator['extract_translate_texts'](ROOT / 'oci/remote'))
|
||||
self.assertTrue(set(KEYS) - {PORT} <= found, (set(KEYS) - {PORT}) - found)
|
||||
self.assertIn(PORT, shell_keys)
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||
cache = json.loads(path.read_text())
|
||||
module._language, module._cache = path.stem, cache
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key)
|
||||
module._cache = {}
|
||||
for key in KEYS:
|
||||
self.assertEqual(module.translate(key), key)
|
||||
module._language, module._cache = 'it', {key: 'IT: ' + key for key in KEYS}
|
||||
self.assertIn('IT: ' + PREVIEW, self.preview(translated=module.translate))
|
||||
self.assertIn('IT: ' + OPTIONAL, self.preview(translated=module.translate))
|
||||
|
||||
def test_actual_shell_guard_fails_for_missing_tcp_contract_not_web_role(self):
|
||||
source = INSTALL.read_text()
|
||||
function = source[source.index('validate_host_monitor_firewall() {'):source.index('\napply_host_monitor_firewall() {')]
|
||||
script = '''set -eu
|
||||
translate() { printf '%s' "$1"; }
|
||||
die() { printf '%s' "$1" >&2; exit 7; }
|
||||
jq() {
|
||||
case "$*" in
|
||||
*'.host_firewall == null'*) printf 'false\\n' ;;
|
||||
*'.host_firewall | type'*) printf 'true\\n' ;;
|
||||
*'.host_firewall.bridge'*) printf 'vmbr0\\n' ;;
|
||||
*'.host_firewall.source'*) printf '192.0.2.0/24\\n' ;;
|
||||
*'.host_firewall.port'*) printf '8080\\n' ;;
|
||||
*'.proxmox.installer_profile.host_monitor_firewall.web_port'*) printf '8080\\n' ;;
|
||||
*'.container_contract.ports'*) printf '0\\n' ;;
|
||||
*) exit 99 ;;
|
||||
esac
|
||||
}
|
||||
ip() { printf '2: vmbr0 inet 192.0.2.1/24 scope global vmbr0\\n'; }
|
||||
BRIDGE=vmbr0 HOST_MONITOR=netdata DEPLOYMENT_FILE=/inert/deployment TEMPLATE_FILE=/inert/template
|
||||
''' + function + '\nvalidate_host_monitor_firewall\n'
|
||||
result = subprocess.run(['bash', '-c', script], text=True, capture_output=True, check=False)
|
||||
self.assertEqual(result.returncode, 7, result.stderr)
|
||||
self.assertEqual(result.stderr, PORT)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Actual removal UI/guards in SK and EN; real host effects are forbidden."""
|
||||
from contextlib import nullcontext
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import i18n, management
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
import oci_instances as instances
|
||||
import oci_remove as removal
|
||||
import oci_ui
|
||||
|
||||
|
||||
class RemovalSlovakTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
cache = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in (('_language', 'sk'), ('_cache', cache)):
|
||||
p = patch.object(module, name, value)
|
||||
p.start(); self.addCleanup(p.stop)
|
||||
self.records = {}
|
||||
self.make_records(2)
|
||||
self.read = self.start(instances, 'read', side_effect=lambda root, vmid: self.records[vmid])
|
||||
self.start(management, '_selected_configuration', side_effect=self.config)
|
||||
self.bridge = self.start(removal, 'bridge_in_use', return_value=False)
|
||||
self.ui = Mock()
|
||||
self.ui.review.return_value = False
|
||||
self.forbidden = []
|
||||
for module, name in ((subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
|
||||
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink'),
|
||||
(removal.shutil, 'rmtree'), (instances, 'write'), (instances, 'command'),
|
||||
(management, '_run_lifecycle'), (removal, 'run'),
|
||||
(removal, 'remove_host_state'), (removal.oci_console, 'remove_log'),
|
||||
(removal, 'release_bridge'), (removal, 'remove_owned_host_firewall'),
|
||||
(removal, 'release_shared_host_files'), (removal.work_backup, 'forget'),
|
||||
(removal.image_cache, 'prune')):
|
||||
self.forbidden.append(self.start(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
|
||||
|
||||
def start(self, module, name, **kwargs):
|
||||
p = patch.object(module, name, **kwargs)
|
||||
result = p.start(); self.addCleanup(p.stop)
|
||||
return result
|
||||
|
||||
def make_records(self, count):
|
||||
self.records.clear()
|
||||
for offset in range(count):
|
||||
vmid = 999990 + offset
|
||||
self.records[vmid] = {'vmid': vmid, 'installation_id': 'fixture-' + str(vmid),
|
||||
'template': {'catalog_ui': {'title': 'Fixture App'}},
|
||||
'deployment': {'mounts': [{'type': 'host-bind', 'source': '/fixture/saved',
|
||||
'container_path': '/saved'}]}}
|
||||
if offset:
|
||||
self.records[vmid]['stack_member'] = {'primary_vmid': 999990}
|
||||
if count > 1:
|
||||
self.records[999990]['stack'] = {'members': [{'vmid': vmid} for vmid in self.records],
|
||||
'deployment': {'network': {'private_bridge': 'vmbr999', 'private_subnet': '10.77.99.0/24'}}}
|
||||
self.records[999990]['deployment']['host_firewall'] = {'port': 8080}
|
||||
self.original = copy.deepcopy(self.records)
|
||||
|
||||
def config(self, vmid):
|
||||
return (f'hostname: fixture-{vmid}\nrootfs: fixture:vm-{vmid}-disk-0,size=8G\n'
|
||||
f'mp0: fixture:vm-{vmid}-disk-1,mp=/data,size=16G\n'
|
||||
'mp1: /fixture/shared,mp=/shared\n').encode()
|
||||
|
||||
def tearDown(self):
|
||||
self.assertEqual(self.records, self.original)
|
||||
for effect in self.forbidden:
|
||||
effect.assert_not_called()
|
||||
|
||||
def test_single_summary_separates_deleted_disks_and_retained_host_paths(self):
|
||||
self.make_records(1)
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Kontajnery určené na odstránenie:', text)
|
||||
self.assertIn('Údaje kontajnera určené na vymazanie:', text)
|
||||
self.assertIn('/data (16G)', text)
|
||||
kept = text.split('Cesty hostiteľa nájdené')[1]
|
||||
self.assertIn('/fixture/shared', kept); self.assertIn('/fixture/saved', kept)
|
||||
self.assertNotIn('/fixture/shared', text.split('Cesty hostiteľa nájdené')[0])
|
||||
self.assertNotIn('súkromnej siete:', text)
|
||||
|
||||
def test_primary_summary_counts_are_grammatical_for_two_three_and_five(self):
|
||||
for count in (2, 3, 5):
|
||||
with self.subTest(count=count):
|
||||
self.make_records(count)
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn(f'Počet kontajnerov aplikácie Fixture App: {count}.', text)
|
||||
self.assertIn('Všetky sú určené na odstránenie.', text)
|
||||
for vmid in self.records:
|
||||
self.assertIn(f'CT {vmid}', text)
|
||||
|
||||
def test_member_summary_warns_that_entire_application_is_selected(self):
|
||||
text = management._removal_summary(ROOT / 'oci', 999991)
|
||||
self.assertIn('CT 999991 patrí do aplikácie Fixture App (počet kontajnerov: 2).', text)
|
||||
self.assertIn('odstránenie celej aplikácie', text)
|
||||
self.assertNotIn('nádob', text)
|
||||
|
||||
def test_free_bridge_is_only_targeted_and_firewall_removal_is_conditional(self):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Súkromná sieť určená na uvoľnenie, ak ju nepoužíva iná VM alebo iný CT: vmbr999', text)
|
||||
self.assertIn('Môže sa odstrániť aj zodpovedajúce spravované pravidlo firewallu hostiteľa.', text)
|
||||
|
||||
def test_shared_bridge_is_kept_and_not_reported_as_released(self):
|
||||
self.bridge.return_value = True
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Súkromná sieť zachovaná', text)
|
||||
self.assertNotIn('určená na uvoľnenie', text)
|
||||
|
||||
def test_missing_config_still_discloses_saved_host_paths_without_deleting(self):
|
||||
with patch.object(management, '_selected_configuration', side_effect=OSError('fixture missing')):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('/fixture/saved', text)
|
||||
self.assertIn('rootfs -', text)
|
||||
|
||||
def test_no_from_real_remove_review_never_calls_lifecycle(self):
|
||||
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999991))
|
||||
review = self.ui.review.call_args
|
||||
self.assertFalse(review.kwargs['default'])
|
||||
self.assertEqual(review.kwargs['question'],
|
||||
'Odstrániť aplikáciu? Jej kontajnerové disky sa vymažú a obnoviť ich možno iba zo zálohy.')
|
||||
|
||||
def test_cancelled_real_remove_review_never_calls_lifecycle(self):
|
||||
self.ui.review.side_effect = UserCancelled('fixture cancelled')
|
||||
with self.assertRaises(UserCancelled):
|
||||
management._remove(ROOT / 'oci', self.ui, 999990)
|
||||
|
||||
def test_preparation_error_does_not_show_confirmation_or_call_lifecycle(self):
|
||||
with patch.object(removal, 'members_of', side_effect=ValueError('fixture record invalid')):
|
||||
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999990))
|
||||
self.ui.review.assert_not_called()
|
||||
self.assertIn('Odstránenie nebolo možné pripraviť:', self.ui.message.call_args.args[0])
|
||||
|
||||
def test_pending_operation_error_precedes_configuration_probe_and_all_mutations(self):
|
||||
self.make_records(1)
|
||||
self.records[999990]['pending_transaction'] = {'fixture': True}
|
||||
self.original = copy.deepcopy(self.records)
|
||||
with patch.object(removal, 'guest_config') as config:
|
||||
with self.assertRaisesRegex(ValueError, 'Operácia tejto inštalácie sa ešte nedokončila'):
|
||||
removal.remove(Path('/fixture/oci'), 999990)
|
||||
config.assert_not_called()
|
||||
|
||||
def test_container_on_other_node_errors_before_host_cleanup(self):
|
||||
with patch.object(removal, 'guest_config', return_value=None), \
|
||||
patch.object(removal, 'guest_node', return_value='fixture-other-node'):
|
||||
with self.assertRaisesRegex(ValueError, 'Kontajner beží na inom uzle klastra'):
|
||||
removal.remove(Path('/fixture/oci'), 999990)
|
||||
|
||||
def run_main(self, result=False, busy=False):
|
||||
with patch.object(sys, 'argv', ['oci_remove.py', '999990']), \
|
||||
patch.object(removal.os, 'geteuid', return_value=0), \
|
||||
patch.object(instances, 'locked', side_effect=BlockingIOError if busy else None,
|
||||
return_value=nullcontext()), \
|
||||
patch.object(removal, 'remove', return_value=result) as mocked_remove, \
|
||||
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_error') as error:
|
||||
code = removal.main()
|
||||
return code, mocked_remove, ok, error
|
||||
|
||||
def test_busy_registry_error_does_not_enter_removal(self):
|
||||
code, remove, ok, error = self.run_main(busy=True)
|
||||
self.assertEqual(code, 1); remove.assert_not_called(); ok.assert_not_called()
|
||||
self.assertEqual(error.call_args.args[0], 'Register inštancií používa iná operácia OCI')
|
||||
|
||||
def test_partial_completion_is_a_warning_to_review_not_unqualified_success(self):
|
||||
code, remove, ok, error = self.run_main(result=True)
|
||||
self.assertEqual(code, 0); error.assert_not_called()
|
||||
self.assertEqual(ok.call_args.args[0],
|
||||
'Príkaz na odstránenie sa dokončil; skontrolujte všetky upozornenia vyššie.')
|
||||
self.assertNotEqual(ok.call_args.args[0], i18n.translate('The application was removed'))
|
||||
|
||||
def test_clean_completion_keeps_existing_success_contract(self):
|
||||
code, remove, ok, error = self.run_main()
|
||||
self.assertEqual(code, 0); error.assert_not_called()
|
||||
self.assertEqual(ok.call_args.args[0], 'Aplikácia bola odstránená')
|
||||
|
||||
def test_english_primary_and_member_summaries_keep_identical_sentences(self):
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
primary = management._removal_summary(ROOT / 'oci', 999990)
|
||||
member = management._removal_summary(ROOT / 'oci', 999991)
|
||||
self.assertTrue(primary.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
|
||||
self.assertTrue(member.startswith('CT 999991 is one of the 2 containers of Fixture App. It cannot be removed on its own,'))
|
||||
|
||||
def test_missing_translation_falls_back_to_the_same_english_summary(self):
|
||||
with patch.object(i18n, '_cache', {}):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertTrue(text.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
|
||||
|
||||
def test_yes_only_builds_the_existing_command_when_lifecycle_is_inert(self):
|
||||
self.ui.review.return_value = True
|
||||
with patch.object(management, '_run_lifecycle', return_value=True) as inert:
|
||||
self.assertTrue(management._remove(ROOT / 'oci', self.ui, 999990))
|
||||
self.assertEqual(inert.call_args.args[0],
|
||||
[sys.executable, str(ROOT / 'oci/remote/oci_remove.py'), '999990'])
|
||||
|
||||
def test_single_and_stack_menu_routes_use_the_translated_label_and_real_no_review(self):
|
||||
for count in (1, 2):
|
||||
with self.subTest(count=count):
|
||||
self.make_records(count)
|
||||
self.ui.reset_mock()
|
||||
self.ui.choose.return_value = 'remove'
|
||||
row = {'vmid': 999990, 'stack': count > 1, 'pending': False,
|
||||
'status': 'installed', 'reason': 'matched'}
|
||||
with patch.object(management, 'carry_records', return_value={}), \
|
||||
patch.object(management, 'check_selected', return_value=row):
|
||||
self.assertFalse(management.manage_instance(ROOT / 'oci', self.ui, row))
|
||||
options = self.ui.choose.call_args.args[1]
|
||||
self.assertIn(('remove', 'Odstrániť: zmazať aplikáciu a jej kontajnery'), options)
|
||||
self.ui.review.assert_called_once()
|
||||
self.assertFalse(self.ui.review.call_args.kwargs['default'])
|
||||
|
||||
def test_changed_identity_warning_does_not_claim_the_other_container_was_deleted(self):
|
||||
self.make_records(1)
|
||||
# Exercise the real backend branch; record cleanup is inert as well.
|
||||
with patch.object(removal, 'guest_config', return_value=b'fixture'), \
|
||||
patch.object(instances, 'identity', return_value='fixture-other-identity'), \
|
||||
patch.object(removal, 'remove_owned_host_firewall', return_value=True), \
|
||||
patch.object(removal, 'release_shared_host_files'), \
|
||||
patch.object(removal.work_backup, 'forget'), \
|
||||
patch.object(removal.image_cache, 'prune', return_value=[]), \
|
||||
patch.object(Path, 'exists', return_value=False), \
|
||||
patch.object(Path, 'is_dir', return_value=False), \
|
||||
patch.object(removal, 'msg_warn') as warning, \
|
||||
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_info'):
|
||||
self.assertTrue(removal.remove(Path('/fixture/oci'), 999990))
|
||||
self.assertEqual(warning.call_args.args[0],
|
||||
'VMID teraz patrí inému kontajneru; ten zostane nedotknutý: CT 999990')
|
||||
self.assertNotIn('Odstránený kontajner:', str(ok.mock_calls))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Exercise the real OCI preview/import functions without importing host-management modules."""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
|
||||
ROWS = {
|
||||
'It needs a privileged container, which is not isolated from the host.':
|
||||
'This profile requires a privileged LXC, which reduces isolation from the host.',
|
||||
'It asks for capabilities or a relaxed confinement profile.':
|
||||
'A relaxed AppArmor or seccomp profile is requested; this may be optional.',
|
||||
'The image expects files that are given to it one by one:':
|
||||
'These container mount paths have an extension-like suffix:',
|
||||
'ProxMenux attaches directories, not single files, so this image cannot be installed yet.':
|
||||
'This import rejects these paths without checking whether they are files or directories.',
|
||||
}
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def extracted(name, **deps):
|
||||
node = next(n for n in ast.parse(CUSTOM.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
scope = {'Any': Any, 'Path': Path, 'yaml': yaml, 'ConversionError': ConversionError,
|
||||
'translate': lambda text: text}
|
||||
scope.update(deps)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(CUSTOM), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class SecurityMountWording(unittest.TestCase):
|
||||
def test_security_warnings_match_their_guards_without_claiming_capabilities(self):
|
||||
describe = extracted('describe', blocker_text=str)
|
||||
template = {'container_contract': {'image': {'reference': 'test:latest'},
|
||||
'volumes': [], 'ports': [], 'environment': []},
|
||||
'proxmox': {'security_profile': {}}}
|
||||
for profile, present in (
|
||||
({}, ()),
|
||||
({'optional_privileged_lxc': True}, ()),
|
||||
({'requires_privileged_lxc': True}, (ROWS['It needs a privileged container, which is not isolated from the host.'],)),
|
||||
({'requires_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
|
||||
({'source_requests_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
|
||||
({'requires_privileged_lxc': True, 'source_requests_relaxed_confinement': True},
|
||||
(ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
ROWS['It asks for capabilities or a relaxed confinement profile.'])),
|
||||
):
|
||||
with self.subTest(profile=profile):
|
||||
template['proxmox']['security_profile'] = profile
|
||||
text = describe(template)
|
||||
for warning in (ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
ROWS['It asks for capabilities or a relaxed confinement profile.']):
|
||||
if warning in present:
|
||||
self.assertIn(' ' + warning, text)
|
||||
else:
|
||||
self.assertNotIn(warning, text)
|
||||
template['proxmox']['security_profile'] = {}
|
||||
template['proxmox']['installer_profile'] = {'security': {'required_capabilities': ['NET_ADMIN']}}
|
||||
text = describe(template)
|
||||
self.assertNotIn(ROWS['It asks for capabilities or a relaxed confinement profile.'], text)
|
||||
|
||||
def test_suffix_hit_rejects_dotted_directory_without_claiming_file_type(self):
|
||||
fn = extracted('template_from_compose',
|
||||
_services=lambda d: d['services'], _check_mounts=lambda s: None,
|
||||
normalize_app_id=lambda s: s, _published_port=lambda s: None,
|
||||
_dump=lambda s: '', _keep_reference=lambda *a: None,
|
||||
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
|
||||
convert_casaos_compose=lambda *args: {'container_contract': {
|
||||
'image': {'reference': 'example:latest'},
|
||||
'volumes': [{'container_path': '/srv/data.v2', 'installation_choice': ['managed-volume', 'host-bind']}]},
|
||||
'proxmox': {}})
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
fn('services:\n app:\n image: example:latest\n')
|
||||
self.assertEqual(str(caught.exception),
|
||||
ROWS['The image expects files that are given to it one by one:'] + ' /srv/data.v2. ' +
|
||||
ROWS['ProxMenux attaches directories, not single files, so this image cannot be installed yet.'])
|
||||
|
||||
def test_suffix_free_path_still_accepted(self):
|
||||
fn = extracted('template_from_compose',
|
||||
_services=lambda d: d['services'], _check_mounts=lambda s: None,
|
||||
normalize_app_id=lambda s: s, _published_port=lambda s: None,
|
||||
_dump=lambda s: '', _keep_reference=lambda *a: None,
|
||||
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
|
||||
convert_casaos_compose=lambda *args: {'container_contract': {
|
||||
'image': {'reference': 'example:latest'},
|
||||
'volumes': [{'container_path': '/srv/config', 'installation_choice': ['managed-volume', 'host-bind']}]},
|
||||
'proxmox': {}})
|
||||
self.assertEqual(fn('services:\n app:\n image: example:latest\n')['container_contract']['volumes'][0]['container_path'], '/srv/config')
|
||||
|
||||
def test_all_four_keys_extract_and_fallback_in_seven_shipped_locales(self):
|
||||
found = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(ROWS), 4)
|
||||
self.assertTrue(set(ROWS.values()) <= set(found), set(ROWS.values()) - set(found))
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for new in ROWS.values():
|
||||
self.assertEqual(module.translate(new), cache.get(new) or new, (path.name, new))
|
||||
setattr(module, '_cache', {})
|
||||
for new in ROWS.values():
|
||||
self.assertEqual(module.translate(new), new, (path.name, new, 'synthetic missing'))
|
||||
module._language, module._cache = 'it', {new: 'IT: ' + new for new in ROWS.values()}
|
||||
self.assertIn('IT: ' + ROWS['It needs a privileged container, which is not isolated from the host.'],
|
||||
extracted('describe', translate=module.translate, blocker_text=str)({
|
||||
'container_contract': {'image': {'reference': 'test:latest'}, 'volumes': [], 'ports': [], 'environment': []},
|
||||
'proxmox': {'security_profile': {'requires_privileged_lxc': True}}}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Inert extracted OCI selection/setup consumers; no admin module imports or scripts sourced."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
OLD_EMPTY = 'No OCI instances are registered.'
|
||||
EMPTY = 'No registered OCI containers are available for selection on this host.'
|
||||
OLD_REPLAY = 'This stack requires replaying specific rootfs adaptations. Coordinated updates are not yet enabled for it.'
|
||||
NO_MEMBERS = 'This stack has no saved members to update.'
|
||||
REPLAY = 'This stack needs rootfs adaptations that coordinated updates cannot replay yet.'
|
||||
HTTP = 'The application did not pass its HTTP check:'
|
||||
|
||||
|
||||
def extracted(name, **dependencies):
|
||||
node = next(n for n in ast.parse(MENU.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
# Drop only infrastructure imports, retaining all selection, guard, and return logic.
|
||||
node.body = [n for n in node.body if not (isinstance(n, ast.Import) and
|
||||
any(a.name == 'oci_instances' or a.name == 'oci_stack_replay' for a in n.names))]
|
||||
for n in ast.walk(node):
|
||||
if isinstance(n, (ast.If, ast.With)):
|
||||
n.body = [x for x in n.body if not (isinstance(x, ast.Import) and
|
||||
any(a.name in ('oci_instances', 'oci_stack_replay') for a in x.names))]
|
||||
scope = dict(dependencies)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||
str(MENU), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class SelectionSetupWording(TestCase):
|
||||
def test_selector_empty_explains_availability_not_registry_absence(self):
|
||||
ui = SimpleNamespace(message=Mock(), choose=Mock())
|
||||
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
|
||||
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
|
||||
_clean_orphans=lambda project: None,
|
||||
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
|
||||
carry_records=lambda project, **options: {}, _records_unread=lambda project: False,
|
||||
translate=lambda s: s)
|
||||
fn(Path('/fixture'), ui)
|
||||
ui.message.assert_called_once_with(EMPTY, 'OCI management')
|
||||
ui.choose.assert_not_called()
|
||||
|
||||
def _menu(self, inventories, selections, unread=True):
|
||||
rows = iter(inventories)
|
||||
ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections))
|
||||
refresh = Mock(return_value={})
|
||||
manage = Mock(return_value=True)
|
||||
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
|
||||
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows),
|
||||
_clean_orphans=lambda project: None,
|
||||
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
|
||||
carry_records=refresh, _records_unread=lambda project: unread,
|
||||
manage_instance=manage, menu_size=lambda: 10,
|
||||
STATUS_LABELS={}, translate=lambda s: s)
|
||||
fn(Path('/fixture'), ui)
|
||||
return ui, refresh, manage
|
||||
|
||||
def test_the_list_comes_back_after_an_operation_without_refreshing_every_copy(self):
|
||||
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
|
||||
ui, refresh, manage = self._menu([[row], [row]], ['101', None])
|
||||
self.assertEqual(ui.choose.call_count, 2)
|
||||
manage.assert_called_once()
|
||||
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True,
|
||||
heading='Manage installed OCI applications')
|
||||
|
||||
def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self):
|
||||
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
|
||||
_, refresh, _ = self._menu([[row]], [None], unread=False)
|
||||
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False,
|
||||
heading='Manage installed OCI applications')
|
||||
|
||||
def test_removing_the_last_application_returns_without_a_message(self):
|
||||
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
|
||||
ui, _, manage = self._menu([[row], []], ['101'])
|
||||
manage.assert_called_once()
|
||||
ui.message.assert_not_called()
|
||||
|
||||
def test_the_watchdog_refreshes_the_copy_of_the_containers_it_changed(self):
|
||||
refresh = Mock(return_value={})
|
||||
watchdog = SimpleNamespace(set_watchdog=Mock(side_effect=[[101, 102]]))
|
||||
fn = extracted('set_watchdog', sys=SimpleNamespace(path=[]), carry_records=refresh,
|
||||
instances=SimpleNamespace(ROOT=Path('/fixture')))
|
||||
with patch.dict('sys.modules', {'oci_watchdog': watchdog}):
|
||||
self.assertTrue(fn(Path('/fixture'), [102], True))
|
||||
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, vmids=[101, 102])
|
||||
|
||||
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
|
||||
class Entry:
|
||||
name = '101'
|
||||
class Lock:
|
||||
def __enter__(self): return None
|
||||
def __exit__(self, *_): return False
|
||||
fake = SimpleNamespace(ROOT=SimpleNamespace(iterdir=lambda: [Entry()]),
|
||||
locked=lambda _: Lock(), has_contract=lambda *args: True,
|
||||
guest_exists=lambda _: False, read=Mock())
|
||||
fn = extracted('saved_inventory', sys=SimpleNamespace(path=[]),
|
||||
instances=fake, public_row=Mock())
|
||||
self.assertEqual(fn(Path('/fixture')), [])
|
||||
fake.read.assert_not_called()
|
||||
|
||||
def test_stack_without_members_is_not_diagnosed_as_replay_failure(self):
|
||||
self._stack({}, NO_MEMBERS).choose.assert_not_called()
|
||||
|
||||
def test_stack_unsupported_replay_retains_distinct_verdict(self):
|
||||
# It cannot be updated, but it can still be changed or removed.
|
||||
ui = self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
|
||||
ui.choose.assert_called_once()
|
||||
self.assertEqual([tag for tag, _ in ui.choose.call_args.args[1]], ['modify', 'watchdog', 'remove'])
|
||||
|
||||
def _stack(self, stack, expected):
|
||||
record = {'stack': stack}
|
||||
fake = SimpleNamespace(ROOT=Path('/fixture'), read=lambda *args: record)
|
||||
replay = SimpleNamespace(**{n + '_menu_ready': lambda _: False
|
||||
for n in ('nextcloud', 'paperless', 'tandoor', 'immich')})
|
||||
ui = SimpleNamespace(message=Mock(), choose=Mock(return_value=None), review=Mock())
|
||||
fn = extracted('_manage_stack', sys=SimpleNamespace(path=[]), instances=fake,
|
||||
oci_stack_replay=replay, translate=lambda s: s)
|
||||
self.assertFalse(fn(Path('/fixture'), ui, {'vmid': 101}))
|
||||
ui.message.assert_called_once_with(expected, 'OCI stack management')
|
||||
ui.review.assert_not_called()
|
||||
return ui
|
||||
|
||||
def test_actual_http_failure_seams_preserve_app_and_log_redirect(self):
|
||||
for app, port in (('paperless', ':8000'), ('tandoor', '')):
|
||||
with self.subTest(app=app):
|
||||
path = ROOT / f'oci/remote/install_{app}_stack.sh'
|
||||
lines = path.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
|
||||
# Actual Bash failure expression, never source the parent installer.
|
||||
harness = '''set -u
|
||||
APPLICATION_LAN_IP=192.0.2.1
|
||||
APPLICATION_ID=101
|
||||
OCI_LOG=/dev/null
|
||||
curl() { return 7; }
|
||||
oci_curl() { shift; curl "$@"; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
print_first_boot_diagnostics() { :; }
|
||||
die() { printf 'ERROR:%s\\n' "$1"; }
|
||||
''' + command + '\n'
|
||||
run = subprocess.run(['bash', '-c', harness], text=True,
|
||||
capture_output=True, timeout=3)
|
||||
self.assertEqual(run.returncode, 0, run.stderr)
|
||||
self.assertEqual(run.stdout.strip(),
|
||||
f'ERROR:{HTTP} {"Paperless-ngx" if app == "paperless" else "Tandoor"}')
|
||||
self.assertIn('2>>"$OCI_LOG"', command)
|
||||
|
||||
def test_nextcloud_explicit_setup_guard_kept(self):
|
||||
source = (ROOT / 'oci/remote/install_nextcloud_stack.sh').read_text()
|
||||
self.assertIn("jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false'", source)
|
||||
self.assertIn('die "$(translate "The application did not complete its initial setup:") Nextcloud"', source)
|
||||
|
||||
def test_shell_lookup_shipped_missing_and_synthetic_translation(self):
|
||||
source = (ROOT / 'oci/remote/oci_ui.sh').read_text()
|
||||
start = source.index('translate() {')
|
||||
fn = source[start:source.index('\n}', start) + 2]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
missing = Path(tmp) / 'missing.json'
|
||||
missing.write_text('{}')
|
||||
translated = Path(tmp) / 'translated.json'
|
||||
translated.write_text(json.dumps({HTTP: 'VERIFICA HTTP FALLITA:'}))
|
||||
for path in [*sorted((ROOT / 'lang').glob('*.json')), missing, translated]:
|
||||
cache = json.loads(path.read_text())
|
||||
bash = '_OCI_LANGUAGE=it\n_OCI_LANG_FILE="$1"\n' + fn + '\ntranslate "$2"\n'
|
||||
result = subprocess.run(['bash', '-c', bash, 'fixture', str(path), HTTP],
|
||||
text=True, capture_output=True, timeout=3)
|
||||
self.assertEqual(result.returncode, 0, (path, result.stderr))
|
||||
self.assertEqual(result.stdout, cache.get(HTTP) or HTTP, path)
|
||||
|
||||
def test_extraction_and_real_lookup_all_locales(self):
|
||||
import runpy
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
extracted_keys = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
for key in (EMPTY, NO_MEMBERS, REPLAY):
|
||||
self.assertIn(key, extracted_keys)
|
||||
shell_keys = generator['extract_translate_texts'](ROOT / 'oci/remote')
|
||||
self.assertIn(HTTP, shell_keys)
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||
cache = json.loads(path.read_text())
|
||||
module._language, module._cache = path.stem, cache
|
||||
for key in (EMPTY, NO_MEMBERS, REPLAY, HTTP):
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key)
|
||||
module._cache = {}
|
||||
self.assertEqual(module.translate(EMPTY), EMPTY)
|
||||
module._cache = {EMPTY: 'LOCALIZED SELECTOR'}
|
||||
self.assertEqual(module.translate(EMPTY), 'LOCALIZED SELECTOR')
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Exercise the multi-container editor in Slovak without applying a change."""
|
||||
from contextlib import redirect_stderr
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import host, i18n, installer, management, stack_recreation as editor
|
||||
import oci_immich_recognition as recognition
|
||||
import oci_instances as instances
|
||||
import oci_stack_modify as modify
|
||||
import oci_ui
|
||||
|
||||
CONFIG = {
|
||||
'rootfs': 'fixture:vm-999991-disk-0,size=8G',
|
||||
'mp0': 'fixture:vm-999991-disk-1,mp=/opt/recipes/mediafiles,size=8G',
|
||||
'mp1': 'fixture:vm-999991-disk-2,mp=/extra,size=4G',
|
||||
'mp2': '/fixture/shared,mp=/shared',
|
||||
'dev0': 'path=/dev/ttyACM7,mode=0660',
|
||||
}
|
||||
|
||||
|
||||
class StackEditorSlovakTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.translations = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in (('_language', 'sk'), ('_cache', self.translations)):
|
||||
item = patch.object(module, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
self.member = {'vmid': 999991, 'stack_member': {'name': 'application'},
|
||||
'deployment': {'replay_profile': {'adapter': 'install_tandoor_stack.sh',
|
||||
'role': 'application'}}}
|
||||
self.primary = {'vmid': 999990, 'stack': {'members': [self.member,
|
||||
{'vmid': 999992, 'deployment': {'replay_profile': {'role': 'database'}}}]}}
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
self.ui = Mock()
|
||||
self.ui.choose.return_value = None
|
||||
self.ui.confirm.return_value = False
|
||||
self.ui.review.return_value = False
|
||||
self.ui.checklist.side_effect = lambda text, options, defaults: list(defaults)
|
||||
config = patch.object(editor, '_config', return_value=CONFIG)
|
||||
config.start()
|
||||
self.addCleanup(config.stop)
|
||||
self.forbidden = []
|
||||
for target, name in ((subprocess, 'run'), (subprocess, 'Popen'),
|
||||
(subprocess, 'check_output'), (Path, 'write_text'),
|
||||
(Path, 'write_bytes'), (instances, 'write'),
|
||||
(instances, 'command'), (modify, 'apply'),
|
||||
(recognition, 'apply'), (recognition, 'configure'),
|
||||
(management, '_run_lifecycle')):
|
||||
item = patch.object(target, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
self.forbidden.append(item.start())
|
||||
self.addCleanup(item.stop)
|
||||
self.lifecycle = Mock(side_effect=AssertionError('Editor called lifecycle'))
|
||||
self.forbidden.append(self.lifecycle)
|
||||
|
||||
def tearDown(self):
|
||||
self.assertEqual(self.primary, self.original)
|
||||
for callback in self.forbidden:
|
||||
callback.assert_not_called()
|
||||
|
||||
def shown(self):
|
||||
return '\n'.join(str(call) for call in self.ui.mock_calls)
|
||||
|
||||
def immich(self, acceleration='cpu'):
|
||||
self.member['deployment']['replay_profile'] = {
|
||||
'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}
|
||||
self.member['deployment']['machine_learning'] = {'acceleration': acceleration}
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
return self.member
|
||||
|
||||
def changes(self, **values):
|
||||
return {'add_mounts': [], 'remove_mounts': [], 'add_devices': [],
|
||||
'remove_devices': [], **values}
|
||||
|
||||
def test_editor_only_offers_extra_paths_and_leaves_database_out(self):
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
shown = self.shown()
|
||||
self.assertIn('odznačené sa odstránia po potvrdení', shown)
|
||||
self.assertIn('odznačené sa odpoja po potvrdení', shown)
|
||||
options = self.ui.checklist.call_args_list[0].args[1]
|
||||
self.assertEqual([path for path, label in options], ['/extra', '/shared'])
|
||||
self.assertNotIn('zásobník', shown)
|
||||
|
||||
def test_declining_volume_deletion_keeps_it_and_only_detaches_host_path_in_draft(self):
|
||||
self.ui.checklist.side_effect = [[], ['/dev/ttyACM7']]
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
warning = self.ui.confirm.call_args_list[0].args[0]
|
||||
self.assertIn('trvalé úložisko aj všetky údaje v ňom', warning)
|
||||
self.assertIn('/extra', warning)
|
||||
summary = self.ui.review.call_args.args[0]
|
||||
self.assertIn('- /shared', summary)
|
||||
self.assertNotIn('- /extra', summary)
|
||||
self.assertIn('Spustený kontajner sa reštartuje', summary)
|
||||
self.assertIn('zastavený zostane zastavený', summary)
|
||||
self.assertIn('Databáza ani ostatné kontajnery sa nemenia', summary)
|
||||
|
||||
def test_declined_review_describes_confirmed_volume_deletion_without_performing_it(self):
|
||||
self.ui.checklist.side_effect = [[], []]
|
||||
self.ui.confirm.side_effect = [True, False, False]
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('- /extra', self.ui.review.call_args.args[0])
|
||||
self.assertIn('vymažú aj údaje', self.ui.review.call_args.args[0])
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Použiť tieto zmeny?')
|
||||
|
||||
def test_member_selection_cancels_before_any_configuration_read(self):
|
||||
other = copy.deepcopy(self.member)
|
||||
other['vmid'] = 999993
|
||||
self.primary['stack']['members'].append(other)
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
with patch.object(editor, '_config') as read:
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
read.assert_not_called()
|
||||
self.assertEqual(self.ui.choose.call_args.args[0], 'Kontajner, ktorý chcete upraviť')
|
||||
|
||||
def test_missing_member_error_uses_application_language(self):
|
||||
self.primary['stack']['members'] = []
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('Pre túto aplikáciu nie je uložený zoznam kontajnerov.', self.shown())
|
||||
self.assertIn('Správa OCI aplikácie s viacerými kontajnermi', self.shown())
|
||||
|
||||
def test_immich_action_menu_identifies_recognition_instead_of_video_transcoding(self):
|
||||
self.immich()
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('Vybrať CPU alebo GPU pre rozpoznávanie', self.shown())
|
||||
self.assertIn('Upraviť pripojené cesty a zariadenia', self.shown())
|
||||
|
||||
def test_cpu_only_host_explains_why_no_gpu_choice_is_offered(self):
|
||||
member = self.immich()
|
||||
with patch.object(host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': []}):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
self.assertIn('nenašla použiteľná grafická karta', self.shown())
|
||||
self.assertIn('Rozpoznávanie zostáva na CPU.', self.shown())
|
||||
self.ui.review.assert_not_called()
|
||||
|
||||
def test_gpu_review_explains_whole_application_update_and_kept_data_before_no(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'openvino'
|
||||
found = {'intel': ['/dev/dri/renderD128'], 'amd': [], 'nvidia': []}
|
||||
with patch.object(host, 'gpus', return_value=found):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
choices = self.ui.choose.call_args.args[1]
|
||||
self.assertEqual(choices, [('cpu', 'CPU'), ('openvino', 'Intel GPU')])
|
||||
text = self.ui.review.call_args.args[0]
|
||||
for fragment in ('CPU → Intel GPU', 'pamäť modelov, knižnica a databáza sa zachovajú',
|
||||
'Celá aplikácia sa zastaví a aktualizuje', 'ProxMenux sa pokúsi obnoviť',
|
||||
'ponuky správy OCI'):
|
||||
self.assertIn(fragment, text)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'],
|
||||
'Použiť zvolený CPU/GPU pre rozpoznávanie?')
|
||||
|
||||
def test_unchanged_recognition_does_not_open_confirmation_or_apply(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'cpu'
|
||||
with patch.object(editor, 'recognition_choices', return_value=[('cpu', 'CPU', {}),
|
||||
('cuda', 'NVIDIA GPU', {})]):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
self.ui.review.assert_not_called()
|
||||
self.assertIn('Nič sa nezmenilo.', self.shown())
|
||||
|
||||
def test_experimental_rocm_can_be_declined_before_final_review(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'rocm'
|
||||
choices = [('cpu', 'CPU', {}), ('rocm', 'AMD GPU', {'experimental': True})]
|
||||
with patch.object(editor, 'recognition_choices', return_value=choices), \
|
||||
patch.object(host, 'amd_gpu_name', return_value='Fixture AMD'):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
text = self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('ROCm túto grafickú kartu oficiálne nepodporuje.', text)
|
||||
self.assertIn('GPU prestať reagovať', text)
|
||||
self.assertIn('bez preinštalovania', text)
|
||||
self.assertIn('Použiť napriek tomu GPU s ROCm?', text)
|
||||
self.assertFalse(self.ui.confirm.call_args.args[1])
|
||||
self.ui.review.assert_not_called()
|
||||
|
||||
def test_backend_invalid_volume_size_preserves_path_and_fails_before_mutation(self):
|
||||
mount = {'type': 'managed-volume', 'container_path': '/new-data',
|
||||
'size_gb': 0, 'source': 'fixture'}
|
||||
with patch.object(modify, 'entries', return_value={}):
|
||||
with self.assertRaisesRegex(ValueError, 'Neplatná veľkosť trvalého úložiska: /new-data'):
|
||||
modify.validate(999991, self.changes(add_mounts=[mount]))
|
||||
|
||||
def test_backend_unsupported_device_explains_separate_profile_before_mutation(self):
|
||||
device = {'kind': 'character-device', 'host_path': '/dev/sda'}
|
||||
with patch.object(modify, 'entries', return_value={}):
|
||||
with self.assertRaisesRegex(ValueError, 'NVIDIA a celé stromy zariadení vyžadujú iný profil: /dev/sda'):
|
||||
modify.validate(999991, self.changes(add_devices=[device]))
|
||||
|
||||
def test_root_error_is_localized_without_starting_the_operation(self):
|
||||
with patch.object(modify.os, 'geteuid', return_value=1000), \
|
||||
patch.object(sys, 'argv', ['fixture', '999991', '--changes', '/not-read.json']), \
|
||||
redirect_stderr(io.StringIO()) as output:
|
||||
with self.assertRaises(SystemExit) as result:
|
||||
modify.main()
|
||||
self.assertEqual(result.exception.code, 2)
|
||||
self.assertIn('Vyžadujú sa oprávnenia používateľa root', output.getvalue())
|
||||
self.assertNotIn('required', output.getvalue())
|
||||
|
||||
def test_invalid_recognition_profile_is_localized_without_any_gpu_probe(self):
|
||||
with self.assertRaisesRegex(ValueError, 'Profil GPU pre Immich nebol overený'):
|
||||
recognition.validate('unknown', None, None)
|
||||
|
||||
def test_recognition_errors_distinguish_unchanged_from_incomplete_operation(self):
|
||||
incomplete = RuntimeError('fixture diagnostic')
|
||||
incomplete.recognition_kept = False
|
||||
for error, expected in ((ValueError('fixture diagnostic'), 'Spôsob rozpoznávania v Immichu sa nezmenil:'),
|
||||
(incomplete, 'Zmenu CPU/GPU pre rozpoznávanie v Immichu sa nepodarilo dokončiť:')):
|
||||
with self.subTest(error=type(error).__name__), \
|
||||
patch.object(recognition.os, 'geteuid', return_value=0), \
|
||||
patch.object(sys, 'argv', ['fixture', '999990', '--acceleration', 'cpu']), \
|
||||
patch.object(recognition, 'change', side_effect=error), \
|
||||
patch.object(recognition, 'msg_error') as message:
|
||||
self.assertEqual(recognition.main(), 1)
|
||||
self.assertEqual(message.call_args.args[0], expected + ' fixture diagnostic')
|
||||
|
||||
def test_english_and_missing_translation_fallback_preserve_source_text(self):
|
||||
changes = self.changes(remove_mounts=['/extra'])
|
||||
for language, cache in (('en', self.translations), ('sk', {})):
|
||||
with self.subTest(language=language, empty_cache=not cache), \
|
||||
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
|
||||
text = editor.summary(self.member, changes)
|
||||
self.assertIn('A running container is restarted', text)
|
||||
self.assertIn('- /extra', text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Coordinated update/recreate contracts; no host lifecycle is permitted."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
|
||||
import oci_instances as instances
|
||||
import oci_stack_plan as stack_plan
|
||||
import oci_stack_transaction as transaction
|
||||
import oci_ui
|
||||
|
||||
|
||||
class StackFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.row['stack'] = True
|
||||
self.record.update(installation_id='fiction-main', stack={
|
||||
'id': 'fiction-main', 'members': [
|
||||
{'vmid': vmid, 'installation_id': identity, 'status': 'installed',
|
||||
'stack_member': {'stack_id': 'fiction-main', 'primary_vmid': 110}}
|
||||
for vmid, identity in [(110, 'fiction-main'), (111, 'fiction-dependent')]],
|
||||
'deployment': {'services': [{'vmid': 111}, {'vmid': 110}]}})
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.extra_effects = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
|
||||
self.extra_effects += [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
|
||||
self.extra_effects += [self.guard(transaction, 'execute'), self.guard(transaction, 'save'),
|
||||
self.guard(menu.images, 'offer_removal')]
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
for effect in self.extra_effects:
|
||||
effect.assert_not_called()
|
||||
|
||||
|
||||
class StackCancellationTests(StackFixture):
|
||||
def test_update_and_recreate_no_are_falsy_distinct_cancellation(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action):
|
||||
result = menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
self.assertIs(result, menu.CANCELLED)
|
||||
self.assertFalse(result)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_cancel_returns_six_for_both_actions(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action), patch.object(cli, 'Catalog'):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', action]), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_no_and_escape_return_six_before_any_effect(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for action in ('update', 'recreate'):
|
||||
for code in (1, 255):
|
||||
with self.subTest(action=action, code=code), \
|
||||
patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_confirmation_defaults_and_member_count_are_preserved(self):
|
||||
for action, default in [('update', True), ('recreate', False)]:
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
args, kwargs = self.ui.review.call_args
|
||||
self.assertIn('2 containers', args[0])
|
||||
self.assertIn('main CT: 110', args[0])
|
||||
self.assertEqual(kwargs['default'], default)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_the_application_list_after_no(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action):
|
||||
self.ui.choose.reset_mock()
|
||||
self.ui.choose.side_effect = ['110', action, None]
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_batch_cancel_never_checks_the_next_application(self):
|
||||
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 112, 'title': 'Second'}]
|
||||
self.ui.checklist.return_value = ['all']
|
||||
self.ui.review.side_effect = [True, False]
|
||||
with patch.object(updates, 'UpdateCheck') as checker, \
|
||||
patch.object(menu, 'saved_inventory', return_value=[self.row, {**self.row, 'vmid': 112}]), \
|
||||
patch.object(menu, 'check_selected', side_effect=lambda project, row: row) as selected:
|
||||
checker.return_value.status.return_value = {'available': available}
|
||||
self.assertFalse(updates.select_updates(ROOT/'oci', self.ui, available))
|
||||
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
|
||||
self.no_effects()
|
||||
|
||||
def test_rejected_backup_remains_failure_one_without_dispatch(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
for action in ('update', 'recreate'):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_positive_confirmation_preserves_success_failure_and_exact_commands(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
for action in ('update', 'recreate'):
|
||||
for result in (False, True):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.return_value = result
|
||||
with patch.object(menu.images, 'offer_removal') as offer:
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action,
|
||||
lifecycle_args=('--keep-backup',)), 0 if result else 1)
|
||||
expected = [sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--keep-backup']
|
||||
if action == 'recreate':
|
||||
expected += ['--operation', 'recreate']
|
||||
self.assertEqual(self.lifecycle.call_args.args[0], expected+['--acknowledge-external-data'])
|
||||
self.assertEqual(offer.call_count, int(result))
|
||||
self.assertEqual(self.record, self.original)
|
||||
self.write.assert_not_called()
|
||||
self.command.assert_not_called()
|
||||
|
||||
def test_acknowledgement_is_not_duplicated(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = False
|
||||
menu.direct_management(ROOT/'oci', 110, 'recreate', lifecycle_args=('--acknowledge-external-data',))
|
||||
self.assertEqual(self.lifecycle.call_args.args[0].count('--acknowledge-external-data'), 1)
|
||||
|
||||
|
||||
class StackSlovakTests(StackFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.catalog = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in [('_language', 'sk'), ('_cache', self.catalog)]:
|
||||
item = patch.object(module, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
def test_questions_and_titles_name_the_whole_application(self):
|
||||
for action, question in [('update', 'Aktualizovať celú aplikáciu?'),
|
||||
('recreate', 'Znovu vytvoriť všetky kontajnery aplikácie?')]:
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], question)
|
||||
self.assertIn('aplikáciu s viacerými kontajnermi', self.ui.review.call_args.args[1])
|
||||
self.no_effects()
|
||||
|
||||
def test_recreation_dialog_explains_saved_images_and_restoring_backups(self):
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action='recreate')
|
||||
text = self.ui.review.call_args.args[0]
|
||||
self.assertIn('uložených identifikátorov obrazov', text)
|
||||
self.assertIn('2', text)
|
||||
self.assertIn('110', text)
|
||||
self.assertIn('zálohy sa obnovia', text)
|
||||
self.assertNotIn('Stack', text)
|
||||
self.no_effects()
|
||||
|
||||
def test_shared_missing_primary_error_in_plan_and_publish_before_any_effect(self):
|
||||
primary = copy.deepcopy(self.record)
|
||||
primary['vmid'] = 112
|
||||
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
|
||||
stack_plan.build(primary, {}, {}, 'update')
|
||||
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
|
||||
instances.publish_stack(Path('/fiction-only'), 110, {}, {}, [])
|
||||
self.no_effects()
|
||||
|
||||
def test_missing_member_error_keeps_volume_verification_requirement(self):
|
||||
records = {row['vmid']: row for row in self.record['stack']['members']}
|
||||
with self.assertRaisesRegex(ValueError, 'znova ich vytvorte až po overení ich dátových zväzkov'):
|
||||
stack_plan.build(self.record, records, {110: 'fiction-main'}, 'update')
|
||||
self.no_effects()
|
||||
|
||||
def test_pending_and_conflicting_member_errors_keep_the_identifier(self):
|
||||
records = {row['vmid']: copy.deepcopy(row) for row in self.record['stack']['members']}
|
||||
inventory = {key: row['installation_id'] for key, row in records.items()}
|
||||
records[111]['status'] = 'pending'
|
||||
with self.assertRaisesRegex(ValueError, '^Nedokončená operácia existuje pre CT 111$'):
|
||||
stack_plan.build(self.record, records, inventory, 'update')
|
||||
records[111]['status'] = 'installed'
|
||||
inventory[111] = 'different-installation'
|
||||
with self.assertRaisesRegex(ValueError, '^Iná inštancia už používa VMID 111$'):
|
||||
stack_plan.build(self.record, records, inventory, 'update')
|
||||
self.no_effects()
|
||||
|
||||
def test_plan_failure_is_read_only_and_distinguishes_invalid_identity(self):
|
||||
primary = copy.deepcopy(self.record)
|
||||
primary['stack']['id'] = 'different-stack'
|
||||
with self.assertRaisesRegex(ValueError, '^Nekonzistentná identita aplikácie s viacerými kontajnermi$'):
|
||||
stack_plan.build(primary, {}, {}, 'recreate')
|
||||
self.no_effects()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Slovak storage questions through real builders and UI; installation forbidden."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[3]
|
||||
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
|
||||
from proxmenux_oci import cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self, answers=None):
|
||||
self.answers=answers or {}
|
||||
self.asked=[]
|
||||
self.messages=[]
|
||||
def _answer(self,text,default):
|
||||
self.asked.append(text)
|
||||
return self.answers.get(text,default)
|
||||
def ask(self,text,default=None,**kwargs):return self._answer(text,default if default is not None else '')
|
||||
def password(self,text,**kwargs):return self._answer(text,'fictional-value-for-tests-000000')
|
||||
def choose(self,text,options,default=None,**kwargs):return self._answer(text,default)
|
||||
def confirm(self,text,default=False,**kwargs):return self._answer(text,default)
|
||||
def checklist(self,text,options,defaults=None,**kwargs):return self._answer(text,defaults or [])
|
||||
def review(self,*args,**kwargs):return False
|
||||
def info(self,text):pass
|
||||
def message(self,text,*args):self.messages.append(text)
|
||||
|
||||
|
||||
class StorageFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.catalog=Catalog(ROOT/'oci')
|
||||
self.sk=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
self.forbidden=[]
|
||||
self.patch(i18n,'_language','sk')
|
||||
self.patch(i18n,'_cache',self.sk)
|
||||
self.patch(installer.host,'storages',side_effect=lambda content: [
|
||||
{'storage':'local-lvm','type':'lvmthin','avail':100*2**30},
|
||||
{'storage':'fiction-storage','type':'dir','avail':200*2**30}] if content=='rootdir' else [
|
||||
{'storage':'local','type':'dir','avail':100*2**30}])
|
||||
self.patch(installer.host,'bridges',return_value=[{'iface':'vmbr0','cidr':'192.0.2.10/24'}])
|
||||
self.patch(installer.host,'timezone',return_value='Europe/Bratislava')
|
||||
self.patch(installer.host,'gpus',return_value={'intel':[],'amd':[],'nvidia':False})
|
||||
self.patch(installer.access,'ask_addresses',side_effect=lambda ui,bridge,names,*a:({name:'dhcp' for name in names},None))
|
||||
self.patch(installer.access,'ask_ipv4',return_value=('dhcp',None))
|
||||
for module,name in [(installer,'run_remote_install'),(cli,'run_remote_install'),
|
||||
(cli.images,'offer_removal'),(subprocess,'run'),(subprocess,'Popen'),(subprocess,'check_output'),
|
||||
(Path,'write_text'),(Path,'write_bytes'),(Path,'unlink')]:
|
||||
self.forbidden.append(self.patch(module,name,side_effect=AssertionError('Forbidden effect: '+name)))
|
||||
self.addCleanup(self.no_effects)
|
||||
def patch(self,module,name,*args,**kwargs):
|
||||
item=patch.object(module,name,*args,**kwargs);value=item.start();self.addCleanup(item.stop);return value
|
||||
def no_effects(self):
|
||||
for effect in self.forbidden:effect.assert_not_called()
|
||||
def recorder(self,answers=None):
|
||||
return Recorder({i18n.translate(key):value for key,value in (answers or {}).items()})
|
||||
def build(self,app,answers=None,mode=installer.ADVANCED_MODE):
|
||||
ui=self.recorder(answers)
|
||||
plan=installer.build_deployment(self.catalog.compose(app),ui,mode)
|
||||
return ui,plan
|
||||
|
||||
|
||||
class StorageWordingTests(StorageFixture):
|
||||
def test_tandoor_files_question_and_minimum_error_keep_the_limit(self):
|
||||
ui,plan=self.build('tandoor',{'Files volume size in GB':'9'})
|
||||
self.assertIn('Veľkosť dátového zväzku pre súbory v GB',ui.asked)
|
||||
self.assertEqual(plan['media']['size_gb'],9)
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok súborov Tandoor musí mať najmenej 2 GB'):
|
||||
self.build('tandoor',{'Files volume size in GB':'1'})
|
||||
|
||||
def test_paperless_storage_documents_and_consume_export_names(self):
|
||||
ui,plan=self.build('paperless-ngx',{'Documents volume size in GB':'30',
|
||||
'Where to store the consume and export folders':'managed-volume','Size of each consume/export volume in GB':'3'})
|
||||
for text in ['Úložisko pre údaje a dokumenty aplikácie Paperless',
|
||||
'Kam uložiť priečinky consume a export',
|
||||
'Veľkosť dátového zväzku pre dokumenty v GB',
|
||||
'Veľkosť každého dátového zväzku pre consume a export v GB']:
|
||||
self.assertIn(text,ui.asked)
|
||||
self.assertEqual((plan['media_size_gb'],plan['transfer']['size_gb'],plan['transfer']['backup']),(30,3,True))
|
||||
|
||||
def test_paperless_volume_limit_and_transfer_limit_errors(self):
|
||||
with self.assertRaisesRegex(installer.InstallError,'Trvalé dátové zväzky aplikácie Paperless musia mať najmenej 8 GB'):
|
||||
self.build('paperless-ngx',{'Documents volume size in GB':'7'})
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátové zväzky pre consume a export musia mať najmenej 1 GB'):
|
||||
self.build('paperless-ngx',{'Where to store the consume and export folders':'managed-volume',
|
||||
'Size of each consume/export volume in GB':'0'})
|
||||
|
||||
def test_postgresql_question_and_error_are_shared_by_immich_and_nextcloud(self):
|
||||
for app in ('immich','nextcloud-stack'):
|
||||
with self.subTest(app=app):
|
||||
ui,plan=self.build(app,{'PostgreSQL volume size in GB':'12'})
|
||||
self.assertIn('Veľkosť dátového zväzku PostgreSQL v GB',ui.asked)
|
||||
self.assertEqual(plan['database_size_gb'],12)
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok PostgreSQL musí mať najmenej 8 GB'):
|
||||
self.build(app,{'PostgreSQL volume size in GB':'7'})
|
||||
|
||||
def test_host_directory_remains_outside_the_container_backup(self):
|
||||
ui,plan=self.build('nextcloud-stack',{'Where to store the Nextcloud files, configuration and data':'host-bind',
|
||||
'Shared host directory':'/fiction-only/existing-data'})
|
||||
self.assertIn('Zdieľaný adresár hostiteľa',ui.asked)
|
||||
self.assertEqual((plan['application']['mode'],plan['application']['host_path'],plan['application']['backup']),
|
||||
('host-bind','/fiction-only/existing-data',False))
|
||||
|
||||
def test_default_and_advanced_builders_preserve_language_independent_plans(self):
|
||||
for app in ('jellyfin','immich','nextcloud-stack','paperless-ngx','tandoor'):
|
||||
for mode in (installer.DEFAULT_MODE,installer.ADVANCED_MODE):
|
||||
with self.subTest(app=app,mode=mode):
|
||||
_ui,slovak=self.build(app,mode=mode)
|
||||
with patch.object(i18n,'_language','en'):
|
||||
_ui,english=self.build(app,mode=mode)
|
||||
self.assertEqual(slovak,english)
|
||||
|
||||
def test_multi_path_cancel_and_invalid_selection_preserve_all_members(self):
|
||||
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
|
||||
{'name':'database','deployment':{'mounts':[]}}]
|
||||
original=copy.deepcopy(services)
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':[]})
|
||||
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
|
||||
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertIn('Pridať ďalšie cesty do aplikácie s viacerými kontajnermi',ui.asked)
|
||||
self.assertEqual(services,original)
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main'],
|
||||
'Data location':None})
|
||||
with self.assertRaises(native_ui.UserCancelled):custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertEqual(services,original)
|
||||
|
||||
def test_shared_selection_error_in_device_flow_also_preserves_members(self):
|
||||
services=[{'name':'main','main':True,'deployment':{}}];original=copy.deepcopy(services)
|
||||
ui=self.recorder()
|
||||
with patch.object(extra_devices,'ask_extra_devices',return_value=[{'kind':'usb','host_path':'/fiction-only/device'}]), \
|
||||
patch.object(ui,'checklist',return_value=[]):
|
||||
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
|
||||
extra_devices.ask_stack_extra_devices(ui,services)
|
||||
self.assertEqual(services,original)
|
||||
|
||||
def test_selected_path_is_added_only_to_selected_member_with_backup(self):
|
||||
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
|
||||
{'name':'database','deployment':{'mounts':[]}}]
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main']})
|
||||
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertEqual(services[1]['deployment']['mounts'],[])
|
||||
mount=services[0]['deployment']['mounts'][0]
|
||||
self.assertEqual((mount['source'],mount['container_path'],mount['size_gb'],mount['backup']),
|
||||
('fiction-storage','/media-extra',8,True))
|
||||
self.assertIn('Pridať ďalšiu cestu do aplikácie s viacerými kontajnermi',ui.asked)
|
||||
|
||||
def test_rclone_shared_directory_question_is_localized_without_creating_it(self):
|
||||
ui=self.recorder({'Shared directory for copy/sync operations':'/fiction-only/rclone'})
|
||||
with patch.object(ui,'confirm',return_value=True):
|
||||
plan=installer.build_deployment(self.catalog.compose('rclone'),ui,installer.ADVANCED_MODE)
|
||||
self.assertIn('Zdieľaný adresár na kopírovanie a synchronizáciu',ui.asked)
|
||||
self.assertTrue(any(m.get('source')=='/fiction-only/rclone' and not m['backup'] for m in plan['mounts']))
|
||||
|
||||
|
||||
class InputAndCancellationTests(StorageFixture):
|
||||
def test_dialog_required_value_and_password_use_slovak_then_retry(self):
|
||||
dialog=native_ui.DialogUI()
|
||||
empty=subprocess.CompletedProcess('dialog',0,stderr='')
|
||||
value=subprocess.CompletedProcess('dialog',0,stderr='fiction-value')
|
||||
with patch.object(dialog,'_run',side_effect=[empty,value]),patch.object(dialog,'message') as message:
|
||||
self.assertEqual(dialog.ask('fiction-field'),'fiction-value')
|
||||
message.assert_called_once_with('Táto hodnota je povinná.')
|
||||
with patch.object(dialog,'_run',side_effect=[empty,value,value]),patch.object(dialog,'message') as message:
|
||||
self.assertEqual(dialog.password('fiction-field'),'fiction-value')
|
||||
message.assert_called_once_with('Táto hodnota je povinná.')
|
||||
|
||||
def test_terminal_required_value_and_password_are_also_localized(self):
|
||||
terminal=native_ui.TerminalUI()
|
||||
with patch('builtins.input',side_effect=['','fiction-value']),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(terminal.ask('fiction-field'),'fiction-value')
|
||||
self.assertIn('Táto hodnota je povinná.',output.getvalue())
|
||||
with patch.object(native_ui.getpass,'getpass',side_effect=['','fiction-value','fiction-value']),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(terminal.password('fiction-field'),'fiction-value')
|
||||
self.assertIn('Táto hodnota je povinná.',output.getvalue())
|
||||
|
||||
def test_real_install_flow_storage_cancel_and_final_no_never_dispatch(self):
|
||||
template=self.catalog.compose('tandoor');original=copy.deepcopy(template)
|
||||
dialog=native_ui.DialogUI()
|
||||
for code in (1,255):
|
||||
replies=[subprocess.CompletedProcess('dialog',0,stderr=value) for value in ('','fiction-stack','2','2048')]
|
||||
replies.append(subprocess.CompletedProcess('dialog',code,stderr=''))
|
||||
with self.subTest(code=code),patch.object(dialog,'_run',side_effect=replies) as run:
|
||||
with self.assertRaises(native_ui.UserCancelled):cli.install_template(dialog,template,'tandoor',installer.ADVANCED_MODE)
|
||||
self.assertEqual(run.call_count,5)
|
||||
self.assertEqual(template,original)
|
||||
self.assertIsNone(cli.install_template(self.recorder(),template,'tandoor',installer.ADVANCED_MODE))
|
||||
self.assertEqual(template,original)
|
||||
|
||||
def test_numeric_retry_keeps_previous_answers_and_already_correct_slovak(self):
|
||||
ui=Recorder();wizard=native_ui.BacktrackUI(ui)
|
||||
wizard.answers=[('ask','previous'),('ask','abc')];wizard.cursor=2
|
||||
self.assertTrue(wizard.retry_last(ValueError("invalid literal for int() with base 10: 'abc'")))
|
||||
self.assertEqual(ui.messages,["Hodnota musí byť číslo: 'abc'\n\nZadajte hodnotu znova."])
|
||||
self.assertEqual((wizard.answers,wizard.cursor),([('ask','previous')],0));wizard.close()
|
||||
|
||||
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
|
||||
ui=Mock();ui.choose.side_effect=['custom',None]
|
||||
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
|
||||
patch.object(updates,'UpdateCheck') as checker:
|
||||
check=checker.return_value.__enter__.return_value
|
||||
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
|
||||
self.assertEqual(cli.interactive(self.catalog),0)
|
||||
ui.message.assert_called_once_with('Operáciu sa nepodarilo dokončiť:\n\nfiction-detail')
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Real native OCI menu/CLI paths with all external effects replaced."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
|
||||
import oci_instances as instances
|
||||
import oci_instance_reconcile as reconcile
|
||||
|
||||
|
||||
class CancellationFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.record = {'status': 'installed', 'vmid': 110, 'deployment': {}, 'template': {}}
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.row = dict(vmid=110, reason='matched', stack=False, pending=False, status='installed',
|
||||
title='Fixture application', hostname='fixture', image='fixture:latest')
|
||||
self.ui = Mock()
|
||||
self.ui.unattended = False
|
||||
self.ui.review.return_value = False
|
||||
for module, name, options in [
|
||||
(i18n, '_language', dict(new='en')), (i18n, '_cache', dict(new={})),
|
||||
(menu.os, 'geteuid', dict(return_value=0)), (menu.shutil, 'which', dict(return_value='/fixture/pct')),
|
||||
(native_ui, 'interactive_ui', dict(return_value=self.ui)),
|
||||
(menu, 'recover_automatically', dict(return_value=None)),
|
||||
(menu, 'carry_records', dict(return_value={})),
|
||||
(menu, 'check_selected', dict(side_effect=lambda project, row: row)),
|
||||
(menu, 'saved_inventory', dict(return_value=[self.row])),
|
||||
(menu, '_selected_configuration', dict(return_value=b'fixture configuration')),
|
||||
(instances, 'read', dict(return_value=self.record)),
|
||||
(reconcile, 'propose', dict(return_value=None)),
|
||||
]:
|
||||
item = patch.object(module, name, **options)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
self.lifecycle = self.guard(menu, '_run_lifecycle')
|
||||
self.backup = self.guard(menu, '_work_backup_ready')
|
||||
self.write = self.guard(instances, 'write')
|
||||
self.command = self.guard(instances, 'command')
|
||||
self.commit = self.guard(reconcile, 'commit')
|
||||
|
||||
def guard(self, module, name):
|
||||
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
mock = item.start()
|
||||
self.addCleanup(item.stop)
|
||||
return mock
|
||||
|
||||
def no_effects(self):
|
||||
for mock in [self.lifecycle, self.backup, self.write, self.command, self.commit]:
|
||||
mock.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
|
||||
class UpdateCancellationTests(CancellationFixture):
|
||||
def test_no_is_a_distinct_falsy_result_before_backup_or_lifecycle(self):
|
||||
result = menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
|
||||
self.assertIs(result, menu.CANCELLED)
|
||||
self.assertFalse(result)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_no_returns_cancel_code_instead_of_failure_or_success(self):
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
result = cli.main(['manage', '110', '--action', 'update'])
|
||||
self.assertEqual(result, 6)
|
||||
self.assertEqual(output.getvalue(), '') # The terminal renders the localized outcome once.
|
||||
self.no_effects()
|
||||
|
||||
def test_no_in_stack_confirmation_keeps_every_member_unchanged(self):
|
||||
self.row['stack'] = True
|
||||
self.record['stack'] = {'members': [{'vmid': 110}, {'vmid': 111}]}
|
||||
self.original = copy.deepcopy(self.record)
|
||||
with redirect_stdout(io.StringIO()):
|
||||
result = menu.direct_management(ROOT / 'oci', 110, 'update')
|
||||
self.assertEqual(result, 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_its_list_after_no(self):
|
||||
self.ui.choose.side_effect = ['110', 'update', None]
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT / 'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_batch_no_does_not_start_the_next_application(self):
|
||||
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 111, 'title': 'Second'}]
|
||||
rows = [self.row, {**self.row, 'vmid': 111}]
|
||||
self.ui.checklist.return_value = ['all']
|
||||
self.ui.review.side_effect = [True, False] # Batch review, first application's confirmation.
|
||||
with patch.object(updates, 'UpdateCheck') as checker, patch.object(menu, 'saved_inventory', return_value=rows), \
|
||||
patch.object(menu, 'check_selected', wraps=lambda project, row: row) as selected:
|
||||
checker.return_value.status.return_value = {'available': available}
|
||||
self.assertFalse(updates.select_updates(ROOT / 'oci', self.ui, available))
|
||||
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
|
||||
self.no_effects()
|
||||
|
||||
def test_accepted_update_failure_keeps_failure_code_one(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_accepted_update_preserves_success_and_existing_lifecycle_command(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = True
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 0)
|
||||
self.lifecycle.assert_called_once()
|
||||
command = self.lifecycle.call_args.args[0]
|
||||
self.assertEqual(command[:3], [sys.executable, str(ROOT / 'oci/remote/oci_update_current.py'), '110'])
|
||||
self.assertIn('--acknowledge-external-data', command)
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_actual_lifecycle_failure_remains_failure_after_positive_confirmation(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
|
||||
self.lifecycle.assert_called_once()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_unattended_positive_default_still_reaches_existing_update(self):
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = True
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 0)
|
||||
self.lifecycle.assert_called_once()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_existing_unattended_review_age_busy_and_missing_codes_are_preserved(self):
|
||||
def declined(unattended, *_args, **_kwargs):
|
||||
unattended.declined = 'review'
|
||||
return False
|
||||
with patch.object(reconcile, 'propose', return_value={'details': ['external change'], 'review': True}), \
|
||||
patch.object(menu.UnattendedUI, 'review', declined), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
|
||||
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
|
||||
with patch.object(menu, 'saved_inventory', return_value=[]):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 2)
|
||||
with patch.object(menu, 'recover_automatically', side_effect=BlockingIOError()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_slovak_dialog_and_recovery_choice_have_the_approved_meaning(self):
|
||||
cache = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
|
||||
menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
|
||||
self.assertEqual(i18n.translate('Recover the previous installation'), 'Obnoviť predchádzajúcu inštaláciu')
|
||||
text = self.ui.review.call_args.args[0]
|
||||
for part in ['novší obraz aplikácie', 'overí sa jej integrita', 'pokúsi obnoviť', 'Pripojené adresáre hostiteľa']:
|
||||
self.assertIn(part, text)
|
||||
self.assertNotIn('kanál obrazov', text)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Spustiť aktualizáciu?')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Inert source-as-key checks for OCI update and recreation; no host modules imported."""
|
||||
import ast
|
||||
from contextlib import nullcontext
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
RECREATE = ROOT / 'oci/src/proxmenux_oci/recreation.py'
|
||||
MANAGE = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
UPDATE = ROOT / 'oci/remote/oci_update_current.py'
|
||||
OLD_CONFIRM = 'Apply the options from the current catalog template? Your data and configuration are kept.'
|
||||
CONFIRM = ('Apply options from the current catalog template? New required paths and settings may be requested. '
|
||||
'Review the resulting configuration before recreating the CT.')
|
||||
OLD_GUARD = 'The current template changes the image or identity; an explicit migration is required'
|
||||
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
|
||||
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
|
||||
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
|
||||
PREVIEW = ('If a newer application image is available, the container is stopped and backed up, and the backup '
|
||||
'integrity is verified. The container is then updated using the new image.\n\n'
|
||||
'If the update fails after the container was changed, ProxMenux attempts to restore the previous '
|
||||
'installation from the backup. If the operation remains incomplete, select the application in OCI '
|
||||
'management and choose "Recover the previous installation".\n\n'
|
||||
'Host directories mounted in the container are not restored by the container backup.')
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class RecreationWording(unittest.TestCase):
|
||||
def refresh(self, mutate, proceed=True):
|
||||
old = {'id': 'app-old', 'container_contract': {'image': {'repository': 'example/app', 'reference': 'example/app:v1'},
|
||||
'volumes': [], 'environment': []},
|
||||
'proxmox': {'installer_profile': {}}, 'catalog_ui': {'title': 'old'}}
|
||||
latest = copy.deepcopy(old)
|
||||
mutate(latest)
|
||||
candidate = {'template': old, 'deployment': {'mounts': [], 'environment': [], 'security': {}}}
|
||||
events = []
|
||||
catalog_module = ModuleType('proxmenux_oci.catalog')
|
||||
class Catalog:
|
||||
def __init__(self, root):
|
||||
pass
|
||||
def load_index(self):
|
||||
return {'applications': [{'template_id': old['id'], 'id': 'app'}]}
|
||||
def compose(self, name):
|
||||
return latest
|
||||
catalog_module.Catalog = Catalog
|
||||
ui = SimpleNamespace(confirm=lambda message, default: events.append(('confirm', message, default)) or proceed,
|
||||
info=lambda message: events.append(('info', message)))
|
||||
scope = {'__package__': 'proxmenux_oci', '__file__': str(RECREATE), 'Path': Path, 're': re,
|
||||
'translate': lambda text: text, 'copy': copy}
|
||||
with patch.dict(sys.modules, {'proxmenux_oci.catalog': catalog_module}):
|
||||
try:
|
||||
extracted(RECREATE, 'refresh_template', scope)(candidate, ui)
|
||||
except ValueError as error:
|
||||
events.append(('error', str(error)))
|
||||
return candidate, events
|
||||
|
||||
def test_confirmation_describes_followup_without_preservation_guarantee(self):
|
||||
candidate, events = self.refresh(lambda latest: latest['catalog_ui'].update(title='new'), proceed=False)
|
||||
self.assertEqual(events, [('confirm', CONFIRM, True)])
|
||||
self.assertEqual(candidate['template']['catalog_ui']['title'], 'old')
|
||||
self.assertNotIn(OLD_CONFIRM, str(events))
|
||||
|
||||
def test_guard_names_exact_rejected_identity_and_repository_changes(self):
|
||||
for change in (lambda latest: latest.update(id='app-new'),
|
||||
lambda latest: latest['container_contract']['image'].update(repository='other/app')):
|
||||
with self.subTest(change=change):
|
||||
candidate, events = self.refresh(change)
|
||||
self.assertEqual(events, [('confirm', CONFIRM, True), ('error', GUARD)])
|
||||
self.assertEqual(candidate['template']['id'], 'app-old')
|
||||
|
||||
def test_same_repository_new_tag_does_not_trigger_migration_guard(self):
|
||||
candidate, events = self.refresh(lambda latest: latest['container_contract']['image'].update(reference='example/app:v2'))
|
||||
self.assertEqual(events, [('confirm', CONFIRM, True)])
|
||||
self.assertEqual(candidate['template']['container_contract']['image']['reference'], 'example/app:v2')
|
||||
|
||||
|
||||
class UpdateWording(unittest.TestCase):
|
||||
def test_actual_native_backup_verification_succeeds_or_fails_before_replacement(self):
|
||||
transaction = ROOT / 'oci/remote/oci_instance_transaction.py'
|
||||
for failures in (1, 2):
|
||||
with self.subTest(failed_integrity_checks=failures), tempfile.TemporaryDirectory() as folder:
|
||||
directory = Path(folder)
|
||||
archive = directory / 'vzdump-lxc-101.tar.zst'
|
||||
calls = []
|
||||
def inert_run(*args):
|
||||
calls.append(args)
|
||||
if args[0] == 'vzdump':
|
||||
archive.write_bytes(b'inert fixture')
|
||||
elif args[0] == 'zstd' and sum(call[0] == 'zstd' for call in calls) <= failures:
|
||||
raise RuntimeError('invalid backup')
|
||||
scope = {'run': inert_run, 'log': lambda text: None,
|
||||
'msg_warn': lambda text: None, 'translate': lambda text: text}
|
||||
backup = extracted(transaction, 'verified_backup', scope)
|
||||
if failures == 2:
|
||||
with self.assertRaisesRegex(RuntimeError, 'invalid backup'):
|
||||
backup(101, directory, 'zstd', 'missing backup')
|
||||
else:
|
||||
self.assertEqual(backup(101, directory, 'zstd', 'missing backup'), archive)
|
||||
self.assertEqual([call[0] for call in calls], ['vzdump', 'zstd', 'vzdump', 'zstd'])
|
||||
self.assertTrue(all(call[call.index('--mode') + 1] == 'stop'
|
||||
for call in calls if call[0] == 'vzdump'))
|
||||
apply_source = transaction.read_text()
|
||||
apply_node = next(n for n in ast.parse(apply_source).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == 'apply')
|
||||
calls = [(n.lineno, n.func.id) for n in ast.walk(apply_node) if isinstance(n, ast.Call)
|
||||
and isinstance(n.func, ast.Name) and n.func.id in ('verified_backup', 'checkpoint', 'install_candidate')]
|
||||
backup_line = next(line for line, name in calls if name == 'verified_backup')
|
||||
ready_line = next(line for line, name in calls if name == 'checkpoint' and line > backup_line)
|
||||
install_line = next(line for line, name in calls if name == 'install_candidate')
|
||||
self.assertLess(backup_line, ready_line)
|
||||
self.assertLess(ready_line, install_line)
|
||||
|
||||
def test_preview_blocks_lifecycle_when_cancelled_and_keeps_command_on_accept(self):
|
||||
events = []
|
||||
instances = ModuleType('oci_instances')
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: {'status': 'installed'}
|
||||
instances.command = lambda *args: b'description: owned'
|
||||
reconcile = ModuleType('oci_instance_reconcile')
|
||||
reconcile.propose = lambda *args: None
|
||||
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
|
||||
'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
|
||||
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
||||
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'carry_records': lambda project, **options: {},
|
||||
'_work_backup_ready': lambda project, ui, vmid: True,
|
||||
'_run_lifecycle': lambda command, title: events.append(('run', command, title)) or True}
|
||||
row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
|
||||
decision = False
|
||||
def review(message, title, **kwargs):
|
||||
events.append(('review', message, title, kwargs))
|
||||
return decision
|
||||
ui = SimpleNamespace(review=review, unattended=True)
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_instance_reconcile': reconcile}):
|
||||
manage = extracted(MANAGE, 'manage_instance', scope)
|
||||
self.assertFalse(manage(ROOT / 'oci', ui, row, action='update'))
|
||||
self.assertEqual(events, [('review', PREVIEW, 'Update OCI', {'question': 'Update now?', 'default': True})])
|
||||
decision = True
|
||||
self.assertTrue(manage(ROOT / 'oci', ui, row, action='update'))
|
||||
self.assertEqual(events[-1], ('run', ['python3', str(ROOT / 'oci/remote/oci_update_current.py'), '101',
|
||||
'--acknowledge-external-data'], 'Update OCI'))
|
||||
self.assertNotIn(OLD_PREVIEW, str(events))
|
||||
|
||||
def test_noop_image_digest_returns_before_any_backup_or_apply(self):
|
||||
events = []
|
||||
record = {'status': 'installed', 'observed': {'image': {'manifest_digest': 'sha256:current'}}}
|
||||
instances = SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
|
||||
read=lambda root, vmid: record, command=lambda *args: b'arch: amd64')
|
||||
transaction = SimpleNamespace(candidate_contract=lambda *args: {'deployment': {}},
|
||||
external_changes=lambda *args: {}, preflight=lambda *args: None,
|
||||
require_backup_space=lambda *args: self.fail('backup space checked on no-op'),
|
||||
apply=lambda *args, **kwargs: self.fail('apply on no-op'))
|
||||
scope = {'instances': instances, 'transaction': transaction,
|
||||
'translate': lambda text: text,
|
||||
'msg_info': lambda text: events.append(text), 'msg_ok': lambda text: events.append(text),
|
||||
'resolve_archive': lambda desired, config, current, check: (None, current),
|
||||
'kept_settings': lambda *args: self.fail('kept settings on no-op')}
|
||||
extracted(UPDATE, 'update', scope)(101, keep_backup='not-a-real-storage')
|
||||
self.assertIn('The image is already up to date; nothing was changed.', events)
|
||||
|
||||
def test_new_keys_extracted_and_shipped_and_missing_locale_fallback(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
for key in (CONFIRM, GUARD, PREVIEW):
|
||||
self.assertIn(key, found)
|
||||
scope = {'json': json, 'Path': Path, 'BASE_DIR': ROOT,
|
||||
'_language': 'it', '_cache': None}
|
||||
lookup = extracted(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', scope)
|
||||
scope['language'] = lambda: scope['_language']
|
||||
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||
scope['_language'] = path.stem
|
||||
scope['_cache'] = None
|
||||
values = json.loads(path.read_text())
|
||||
for key in (CONFIRM, GUARD, PREVIEW):
|
||||
self.assertEqual(lookup(key), values.get(key) or key, (path.name, key))
|
||||
scope['_language'] = 'it'
|
||||
for key in (CONFIRM, GUARD, PREVIEW):
|
||||
scope['_cache'] = {}
|
||||
self.assertEqual(lookup(key), key, ('missing', key))
|
||||
scope['_cache'] = {key: 'LOCALIZED: ' + key}
|
||||
self.assertEqual(lookup(key), 'LOCALIZED: ' + key, ('synthetic', key))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,231 @@
|
||||
"""OCI validation record: generated list, record checks, credited authors and the report bot."""
|
||||
import datetime
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
spec = importlib.util.spec_from_file_location("oci_validation", ROOT / ".github/scripts/oci_validation.py")
|
||||
validation = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(validation)
|
||||
|
||||
TODAY = datetime.date(2026, 9, 28)
|
||||
REPORT = "https://github.com/MacRimi/ProxMenux/issues/400"
|
||||
INDEX = {"applications": [
|
||||
{"id": "glances", "title": "Glances", "category_label": "Monitoring", "automatic_install_candidate": True},
|
||||
{"id": "2fauth", "title": "2FAuth", "category_label": "Security", "automatic_install_candidate": True},
|
||||
{"id": "legacy", "title": "Legacy | App", "category_label": "Other", "automatic_install_candidate": False},
|
||||
{"id": "hidden", "title": "Hidden", "hidden": True},
|
||||
]}
|
||||
|
||||
|
||||
def form(**answers):
|
||||
fields = {"Application": "Glances", "Image version": "4.3", "Image digest": "2026-09-06 · B1F339CF",
|
||||
"Proxmox VE version": "9.1", "Install mode": "Default configuration",
|
||||
"Data storage": "Volume on Proxmox storage", "Network": "DHCP", "GPU": "No GPU",
|
||||
"Result": "Works", "Notes": "_No response_"}
|
||||
fields.update(answers)
|
||||
return "\n\n".join(f"### {label}\n\n{value}" for label, value in fields.items())
|
||||
|
||||
|
||||
def event(body=None, labels=("oci-validation", "validated"), user="Tester-1"):
|
||||
return {"issue": {"number": 400, "html_url": REPORT, "user": {"login": user}, "body": form() if body is None else body,
|
||||
"labels": [{"name": name} for name in labels]}}
|
||||
|
||||
|
||||
class Render(unittest.TestCase):
|
||||
def test_lists_community_and_installable_lab_entries(self):
|
||||
record = {"applications": {
|
||||
"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}},
|
||||
"2fauth": {"status": "laboratory-validated"},
|
||||
"legacy": {"status": "laboratory-validated"}}}
|
||||
text = validation.render(record, INDEX)
|
||||
self.assertIn("2 of 3 applications", text)
|
||||
self.assertIn("| Glances | [@Vaso73](https://github.com/Vaso73) | 2026-09-27 | — | — | [Report](" + REPORT + ") |", text)
|
||||
self.assertIn("| 2FAuth | Security |", text)
|
||||
# An application that cannot be installed is not listed as verified.
|
||||
self.assertNotIn("Legacy", text)
|
||||
|
||||
def test_empty_record_and_escaped_titles(self):
|
||||
text = validation.render({"applications": {}}, INDEX)
|
||||
self.assertIn("No application has been tested by the community yet.", text)
|
||||
record = {"applications": {"legacy": {"community_tested": {"by": "a", "date": "2026-09-27"}}}}
|
||||
self.assertIn("| Legacy \\| App |", validation.render(record, INDEX))
|
||||
|
||||
def test_repository_record_is_valid_and_current(self):
|
||||
record = validation.load(validation.VERIFICATION)
|
||||
index = validation.load(validation.INDEX)
|
||||
self.assertEqual(validation.problems(record, index, validation.OUTPUT.read_text(encoding="utf-8")), [])
|
||||
|
||||
|
||||
class Problems(unittest.TestCase):
|
||||
def check(self, entry):
|
||||
return validation.problems({"applications": {"glances": entry}}, INDEX, today=TODAY)
|
||||
|
||||
def test_valid_entries(self):
|
||||
self.assertEqual(self.check({"status": "laboratory-validated"}), [])
|
||||
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}}), [])
|
||||
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27",
|
||||
"report": "https://github.com/MacRimi/ProxMenux/discussions/392#discussioncomment-18623868"}}), [])
|
||||
|
||||
def test_rejected_entries(self):
|
||||
cases = [
|
||||
({"status": "works"}, "status can only be"),
|
||||
({"community_tested": {"by": "bad user", "date": "2026-09-27"}}, "GitHub user name"),
|
||||
({"community_tested": {"by": "a", "date": "27/09/2026"}}, "YYYY-MM-DD"),
|
||||
({"community_tested": {"by": "a", "date": "2027-01-01"}}, "in the future"),
|
||||
({"community_tested": {"by": "a", "date": "2026-09-27", "report": "https://example.com/x"}}, "must link"),
|
||||
({"community_tested": {"by": "a", "date": "2026-09-27", "extra": 1}}, "unknown community_tested keys"),
|
||||
({"verified": True}, "unknown keys"),
|
||||
({}, "non-empty object"),
|
||||
]
|
||||
for entry, message in cases:
|
||||
with self.subTest(entry=entry):
|
||||
self.assertTrue(any(message in problem for problem in self.check(entry)), self.check(entry))
|
||||
self.assertTrue(validation.problems({"applications": {"nope": {"status": "laboratory-validated"}}}, INDEX))
|
||||
|
||||
def test_stale_list_is_reported(self):
|
||||
record = {"applications": {"2fauth": {"status": "laboratory-validated"}}}
|
||||
self.assertTrue(any("out of date" in p for p in validation.problems(record, INDEX, "old", TODAY)))
|
||||
|
||||
|
||||
class Authors(unittest.TestCase):
|
||||
base = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}},
|
||||
"2fauth": {"status": "laboratory-validated"}}}
|
||||
|
||||
def head(self, **changes):
|
||||
data = json.loads(json.dumps(self.base))
|
||||
data["applications"].update(changes)
|
||||
return data
|
||||
|
||||
def test_author_adds_own_test(self):
|
||||
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
|
||||
self.assertEqual(validation.author_problems(self.base, head, "F3rs3n"), [])
|
||||
|
||||
def test_crediting_someone_else_is_refused(self):
|
||||
head = self.head(pocketbase={"community_tested": {"by": "Vaso73", "date": "2026-09-28"}})
|
||||
self.assertEqual(validation.author_problems(self.base, head, "f3rs3n"),
|
||||
["pocketbase: credits @Vaso73, but the pull request is from @f3rs3n"])
|
||||
|
||||
def test_lab_status_and_removals_are_for_the_maintainer(self):
|
||||
head = self.head(glances={}, pocketbase={"status": "laboratory-validated"})
|
||||
found = validation.author_problems(self.base, head, "f3rs3n")
|
||||
self.assertIn("glances: a community test is removed by the maintainer only", found)
|
||||
self.assertIn("pocketbase: the ProxMenux lab status is changed by the maintainer only", found)
|
||||
self.assertEqual(validation.author_problems(self.base, head, "MacRimi"), [])
|
||||
|
||||
def test_missing_base_file(self):
|
||||
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
|
||||
self.assertEqual(validation.author_problems({}, {"applications": {"pocketbase": head["applications"]["pocketbase"]}},
|
||||
"f3rs3n"), [])
|
||||
|
||||
|
||||
class Record(unittest.TestCase):
|
||||
def test_parse_form_reads_github_issue_form_body(self):
|
||||
fields = validation.parse_form(form(Notes="Line one\r\nLine two"))
|
||||
self.assertEqual(fields["Application"], "Glances")
|
||||
self.assertEqual(fields["Notes"], "Line one\nLine two")
|
||||
self.assertEqual(validation.parse_form(form())["Notes"], "")
|
||||
|
||||
def test_records_the_report_author(self):
|
||||
data, app, message = validation.record(event(), {"applications": {"glances": {"status": "laboratory-validated"}}},
|
||||
INDEX, TODAY)
|
||||
self.assertEqual(app, "glances")
|
||||
self.assertEqual(data["applications"]["glances"], {"status": "laboratory-validated", "community_tested": {
|
||||
"by": "Tester-1", "date": "2026-09-28", "report": REPORT, "digest": "b1f339cf",
|
||||
"scenario": "Default configuration · Volume on Proxmox storage · DHCP · No GPU · Proxmox VE 9.1 · image 4.3"}})
|
||||
self.assertIn("@Tester-1", message)
|
||||
self.assertEqual(validation.problems(data, INDEX, today=TODAY), [])
|
||||
|
||||
def test_matches_id_or_title_and_replaces_older_test(self):
|
||||
record = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}}}}
|
||||
data, app, message = validation.record(event(form(Application=" glances ")), record, INDEX, TODAY)
|
||||
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
|
||||
self.assertIn("replaces the previous one from @Vaso73", message)
|
||||
self.assertEqual(record["applications"]["glances"]["community_tested"]["by"], "Vaso73")
|
||||
|
||||
def test_refusals(self):
|
||||
for case, message in [
|
||||
(event(form(Application="Unknown")), "does not match"),
|
||||
(event(form(Result="Does not work")), "does not work"),
|
||||
(event(labels=("validated",)), "not an OCI validation report"),
|
||||
(event(user="bad user"), "could not be read"),
|
||||
(event(form(**{"Image digest": "latest"})), "does not hold an image digest"),
|
||||
]:
|
||||
with self.subTest(message=message):
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
validation.record(case, {"applications": {}}, INDEX, TODAY)
|
||||
|
||||
|
||||
class ReviewerComment(unittest.TestCase):
|
||||
allowed = frozenset({"macrimi", "vaso73", "f3rs3n"})
|
||||
|
||||
def comment(self, reviewer, author="Tester-1"):
|
||||
data = event(labels=("oci-validation",), user=author)
|
||||
data["comment"] = {"user": {"login": reviewer}, "body": "/validated"}
|
||||
return data
|
||||
|
||||
def test_listed_reviewer_validates_and_is_named(self):
|
||||
data, app, message = validation.record(self.comment("Vaso73"), {"applications": {}}, INDEX, TODAY, self.allowed)
|
||||
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
|
||||
self.assertIn("Validated by @Vaso73.", message)
|
||||
|
||||
def test_unlisted_reviewer_and_own_report_are_refused(self):
|
||||
with self.assertRaisesRegex(ValueError, "not in the list of OCI validation reviewers"):
|
||||
validation.record(self.comment("someone"), {"applications": {}}, INDEX, TODAY, self.allowed)
|
||||
with self.assertRaisesRegex(ValueError, "other than its author"):
|
||||
validation.record(self.comment("f3rs3n", author="f3rs3n"), {"applications": {}}, INDEX, TODAY, self.allowed)
|
||||
|
||||
def test_maintainer_may_validate_own_report_and_label_needs_no_list(self):
|
||||
validation.record(self.comment("MacRimi", author="MacRimi"), {"applications": {}}, INDEX, TODAY, self.allowed)
|
||||
validation.record(event(), {"applications": {}}, INDEX, TODAY)
|
||||
|
||||
def test_reviewer_list_file(self):
|
||||
self.assertEqual(validation.reviewers("# comment\nMacRimi\n Vaso73 # tester\nbad name\n"),
|
||||
{"macrimi", "vaso73"})
|
||||
listed = validation.reviewers(validation.REVIEWERS.read_text(encoding="utf-8"))
|
||||
self.assertEqual(listed, self.allowed)
|
||||
|
||||
|
||||
class ImageAndScenario(unittest.TestCase):
|
||||
def test_digest_as_the_monitor_shows_it_or_in_full(self):
|
||||
full = "sha256:" + "ab" * 32
|
||||
self.assertEqual(validation.image_digest("2026-09-06 · b1f339cf"), "b1f339cf")
|
||||
self.assertEqual(validation.image_digest(full.upper()), "ab" * 32)
|
||||
for value in ("", "b1f3", "latest", "sha256:xyz12345"):
|
||||
self.assertIsNone(validation.image_digest(value))
|
||||
|
||||
def test_scenario_names_problems_and_skips_empty_fields(self):
|
||||
fields = validation.parse_form(form(Result="Works with problems", GPU="_No response_"))
|
||||
self.assertEqual(validation.scenario(fields), "Works with problems: Default configuration · "
|
||||
"Volume on Proxmox storage · DHCP · Proxmox VE 9.1 · image 4.3")
|
||||
|
||||
def test_list_shows_image_and_scenario(self):
|
||||
record = {"applications": {"glances": {"community_tested": {
|
||||
"by": "Vaso73", "date": "2026-09-27", "digest": "b1f339cf08ae", "scenario": "Default | DHCP"}}}}
|
||||
self.assertIn("| `b1f339cf08ae` | Default \\| DHCP |", validation.render(record, INDEX))
|
||||
|
||||
def test_bad_digest_and_long_scenario_are_rejected(self):
|
||||
found = validation.problems({"applications": {"glances": {"community_tested": {
|
||||
"by": "a", "date": "2026-09-27", "digest": "XYZ", "scenario": "x" * 301}}}}, INDEX, today=TODAY)
|
||||
self.assertTrue(any("digest" in f for f in found) and any("scenario" in f for f in found), found)
|
||||
|
||||
|
||||
class IssueForm(unittest.TestCase):
|
||||
def test_form_labels_match_the_bot(self):
|
||||
text = (ROOT / ".github/ISSUE_TEMPLATE/oci-validation.yml").read_text(encoding="utf-8")
|
||||
labels = re.findall(r"^\s+label: (.+)$", text, re.M)
|
||||
self.assertIn("Application", labels)
|
||||
self.assertIn("Result", labels)
|
||||
self.assertIn("Image digest", labels)
|
||||
for name in validation.SCENARIO_FIELDS:
|
||||
self.assertIn(name, labels)
|
||||
self.assertIn("- Does not work", text)
|
||||
self.assertIn('labels: ["oci-validation"]', text)
|
||||
self.assertIn("template=oci-validation.yml", validation.FORM)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
|
||||
class OciWatchdogWiring(TestCase):
|
||||
def test_changing_it_needs_an_administrator_and_goes_through_the_engine(self):
|
||||
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
|
||||
route = re.search(r"@app\.route\('/api/lxc/<int:vmid>/oci-watchdog', methods=\['POST'\]\)\n@(\w+)\ndef (\w+)", server)
|
||||
self.assertEqual(route.group(1), 'require_admin_scope')
|
||||
body = server[route.end():server.index("@app.route('/api/vms/<int:vmid>/logs'")]
|
||||
self.assertIn("if not isinstance(enabled, bool):", body)
|
||||
self.assertIn("oci/engine/remote/oci_watchdog.py',", body)
|
||||
self.assertNotIn('shell=True', body)
|
||||
self.assertIn('"watchdog": False,', (ROOT / 'AppImage/scripts/oci_instance_info.py').read_text())
|
||||
|
||||
def test_the_toggle_is_only_offered_for_an_oci_application(self):
|
||||
modal = (ROOT / 'AppImage/components/virtual-machines.tsx').read_text()
|
||||
block = modal[modal.index('{/* Watchdog of an OCI application'):]
|
||||
self.assertTrue(block.split('\n', 3)[2].strip().startswith('{ociInstance?.oci_instance && ('))
|
||||
self.assertIn('t("vmLxc.details.watchdog")', block)
|
||||
self.assertIn('`/api/lxc/${selectedVM.vmid}/oci-watchdog`', modal)
|
||||
|
||||
def test_every_language_names_and_explains_it(self):
|
||||
for locale in LOCALES:
|
||||
details = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['vmLxc']['details']
|
||||
self.assertTrue(details['watchdog'].strip(), locale)
|
||||
self.assertTrue(details['watchdogHelp'].strip(), locale)
|
||||
|
||||
def test_the_engine_restarts_its_service_when_proxmenux_replaces_it(self):
|
||||
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
self.assertIn('systemctl try-restart proxmenux-oci-watchdog.service', (ROOT / installer).read_text(), installer)
|
||||
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
|
||||
self.assertIn("UNIT = 'proxmenux-oci-watchdog.service'", engine)
|
||||
|
||||
|
||||
class OciWatchdogInstaller(TestCase):
|
||||
CLI = (ROOT / 'oci/src/proxmenux_oci/cli.py').read_text()
|
||||
MENU = (ROOT / 'oci/src/proxmenux_oci/management.py').read_text()
|
||||
|
||||
def default(self, template):
|
||||
node = next(item for item in ast.parse(self.CLI).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'watchdog_default')
|
||||
scope = {'Any': object}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), 'cli.py', 'exec'), scope)
|
||||
return scope['watchdog_default'](template)
|
||||
|
||||
def test_the_recipe_decides_what_is_proposed(self):
|
||||
self.assertTrue(self.default({'container_contract': {'restart': 'unless-stopped'}}))
|
||||
self.assertTrue(self.default({'container_contract': {'restart': 'always'}}))
|
||||
self.assertTrue(self.default({'compose_stack': {'services': [{'compose': {}}, {'compose': {'restart': 'on-failure'}}]}}))
|
||||
self.assertFalse(self.default({'container_contract': {'restart': None}}))
|
||||
self.assertFalse(self.default({'container_contract': {'restart': 'no'}, 'compose_stack': {'services': [{'compose': {'restart': 'no'}}]}}))
|
||||
self.assertFalse(self.default({}))
|
||||
|
||||
def test_every_installation_asks_it_and_the_engine_never_receives_the_answer(self):
|
||||
install = self.CLI[self.CLI.index('def install_template('):self.CLI.index('def _rclone_mount(')]
|
||||
asked = install.index('deployment["watchdog"] = wizard.confirm(')
|
||||
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
|
||||
self.assertLess(asked, install.index('approved = wizard.review('))
|
||||
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
|
||||
self._assert_jobs_are_excluded_from_installed_watchdog()
|
||||
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
|
||||
|
||||
def _assert_jobs_are_excluded_from_installed_watchdog(self):
|
||||
node = next(item for item in ast.parse(self.CLI).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'install_template')
|
||||
class InertImports(ast.NodeTransformer):
|
||||
def visit_ImportFrom(self, item):
|
||||
return None
|
||||
node = InertImports().visit(node)
|
||||
deployment = {'services': [
|
||||
{'name': 'migration', 'deployment': {'execution_mode': 'one-shot'}},
|
||||
{'name': 'database', 'deployment': {}},
|
||||
{'name': 'worker', 'deployment': {}}]}
|
||||
result = {'vmid': 4800, 'stack_vmids': {'web': 4800, 'migration': 4801,
|
||||
'database': 4802, 'worker': 4803}}
|
||||
set_watchdog = Mock(return_value=True)
|
||||
class UI:
|
||||
def confirm(self, *args, **kwargs): return True
|
||||
def review(self, *args, **kwargs): return True
|
||||
def close(self): pass
|
||||
def install(project, template, plan, target):
|
||||
self.assertNotIn('watchdog', plan)
|
||||
return result
|
||||
scope = {'Any': Any, 'copy': copy, 'BacktrackUI': lambda ui: ui,
|
||||
'RestartWizard': RuntimeError, 'InstallError': ValueError,
|
||||
'build_deployment': lambda *args: copy.deepcopy(deployment),
|
||||
'watchdog_default': lambda *args: False,
|
||||
'translate': lambda value: value, '_deployment_summary_text': lambda *args: '',
|
||||
'PROJECT_ROOT': Path('/inert'), 'source_text': lambda value: 'test', 'APP_TITLE': 'test',
|
||||
'console': SimpleNamespace(**{name: lambda *args: None for name in
|
||||
('show_logo', 'msg_title', 'msg_error', 'msg_ok', 'msg_warn', 'wait_for_enter')}),
|
||||
'run_remote_install': install, 'carry_records': lambda *args, **kwargs: None,
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: (False, None)),
|
||||
'_print_installation_summary': lambda *args: None, 'set_watchdog': set_watchdog}
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), 'cli.py', 'exec'), scope)
|
||||
scope['install_template'](UI(), {'catalog_ui': {'title': {'en_US': 'test'}}}, 'test', 'advanced')
|
||||
set_watchdog.assert_called_once_with(Path('/inert'), [4800, 4802, 4803], True)
|
||||
|
||||
def test_both_management_menus_offer_it(self):
|
||||
self.assertEqual(self.MENU.count("('watchdog', "), 2)
|
||||
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
|
||||
|
||||
def test_the_service_is_written_through_the_change_journal(self):
|
||||
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
|
||||
self.assertIn('pmx_write_file "$2" && systemctl daemon-reload && pmx_enable_service "$3"', engine)
|
||||
journal = (ROOT / 'scripts/global/pmx_journal.sh').read_text()
|
||||
for helper in ('pmx_journal_context()', 'pmx_write_file()', 'pmx_enable_service()'):
|
||||
self.assertIn(helper, journal)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Offline OCI wording seams; compile actual functions, never import operational modules."""
|
||||
import ast
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
|
||||
TRANSACTION = ROOT / 'oci/remote/oci_instance_transaction.py'
|
||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
CHOICE = 'Recover the previous installation'
|
||||
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
|
||||
AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
|
||||
REGISTRY_ADVICE = ('Check the image reference and registry access. If the registry is unavailable, '
|
||||
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
|
||||
NEW_KEYS = (
|
||||
'Containers of the application:',
|
||||
'No docker run command was given',
|
||||
'Image selection was cancelled',
|
||||
'Could not inspect the image in its registry:',
|
||||
REGISTRY_ADVICE,
|
||||
BEFORE, AFTER,
|
||||
)
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def function(path, name, **dependencies):
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
# Only read_definition's infrastructure import is omitted; keep the function intact.
|
||||
if name == 'read_definition':
|
||||
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
|
||||
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
|
||||
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
|
||||
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024,
|
||||
MAX_OCI_COMPOSE_BYTES=2*1024*1024, OCI_COMPOSE='oci-compose')
|
||||
scope.update(dependencies)
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||
str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
|
||||
|
||||
class OciWordingTests(TestCase):
|
||||
def test_extractor_shipped_locales_and_synthetic_fallback(self):
|
||||
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
extracted = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
|
||||
self.assertEqual(len(NEW_KEYS), len(set(NEW_KEYS)))
|
||||
self.assertTrue(set(NEW_KEYS) <= set(extracted), set(NEW_KEYS) - set(extracted))
|
||||
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
paths = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
for key in NEW_KEYS:
|
||||
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
|
||||
setattr(module, '_cache', {})
|
||||
for key in NEW_KEYS:
|
||||
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
|
||||
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
|
||||
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
|
||||
|
||||
def test_multiple_services_keep_their_names_and_enter_stack_conversion(self):
|
||||
# Reusing one image twice still creates two distinct Compose services.
|
||||
document = {'services': {'alpha': {'image': 'same:latest'},
|
||||
'beta': {'image': 'same:latest'}}}
|
||||
converted = object()
|
||||
stack = Mock(return_value=converted)
|
||||
template = function(CUSTOM, 'template_from_compose',
|
||||
_services=lambda doc: doc['services'], _template_from_stack=stack)
|
||||
self.assertIs(template(json.dumps(document)), converted)
|
||||
stack.assert_called_once()
|
||||
self.assertEqual(stack.call_args.args, (document, None))
|
||||
self.assertFalse(stack.call_args.kwargs['interpolated'])
|
||||
self.assertIsNone(stack.call_args.kwargs.get('image_entrypoints'))
|
||||
|
||||
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
|
||||
for title, expected in ((None, 'No Compose file was given'),
|
||||
('docker run command of the application', 'No docker run command was given')):
|
||||
def paste(content):
|
||||
return function(CUSTOM, '_read_pasted',
|
||||
console=SimpleNamespace(show_logo=lambda: None, msg_title=lambda *_: None,
|
||||
msg_info2=lambda *_: None),
|
||||
sys=SimpleNamespace(stdin=io.StringIO(content)))
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
paste('')(None, title)
|
||||
self.assertEqual(str(caught.exception), expected)
|
||||
self.assertEqual(paste(' literal input\n')(None, title), ' literal input\n')
|
||||
|
||||
def test_cancelled_image_menu(self):
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
function(CUSTOM, 'read_definition')(SimpleNamespace(choose=lambda *_, **__: None))
|
||||
self.assertEqual(str(caught.exception), 'Image selection was cancelled')
|
||||
|
||||
def test_registry_nonzero_and_success_command_unchanged(self):
|
||||
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
|
||||
run = Mock(return_value=result)
|
||||
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run),
|
||||
_registry_lookup=lambda reference: reference)
|
||||
self.assertEqual(report('example:latest'),
|
||||
(False, 'Could not inspect the image in its registry: example:latest'))
|
||||
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
|
||||
capture_output=True, text=True, check=False, timeout=120)
|
||||
run.reset_mock()
|
||||
run.return_value = SimpleNamespace(returncode=0,
|
||||
stdout='{"manifests":[{"platform":{"architecture":"amd64"}}]}', stderr='')
|
||||
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
|
||||
self.assertEqual(run.call_count, 1)
|
||||
|
||||
def test_failed_registry_inspection_points_to_registry_accounts(self):
|
||||
ui = SimpleNamespace(message=Mock())
|
||||
fn = function(CUSTOM, '_explore', read_definition=lambda _: ('definition', 'source'),
|
||||
ignored_settings=lambda _: [], template_from_compose=lambda _: {
|
||||
'container_contract': {'image': {'reference': 'example:latest'}}},
|
||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
|
||||
_registry_refused=lambda _: False)
|
||||
fn(ui)
|
||||
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n' + REGISTRY_ADVICE)
|
||||
self.assertNotIn('not supported yet', ui.message.call_args.args[0])
|
||||
|
||||
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
|
||||
# Pin the real translated management choice, not an English AST constant alone.
|
||||
source = ast.parse(MENU.read_text())
|
||||
self.assertTrue(any(isinstance(n, ast.Tuple) and len(n.elts) == 2
|
||||
and isinstance(n.elts[0], ast.Constant) and n.elts[0].value == 'recover'
|
||||
and isinstance(n.elts[1], ast.Call) and isinstance(n.elts[1].func, ast.Name)
|
||||
and n.elts[1].func.id == 'translate' and n.elts[1].args
|
||||
and isinstance(n.elts[1].args[0], ast.Constant)
|
||||
and n.elts[1].args[0].value == CHOICE for n in ast.walk(source)))
|
||||
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
for path in sorted((ROOT / 'lang').glob('*.json')):
|
||||
cache = json.loads(path.read_text())
|
||||
setattr(module, '_language', path.stem)
|
||||
setattr(module, '_cache', cache)
|
||||
warnings = []
|
||||
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
|
||||
msg_warn=warnings.append)
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
label = module.translate(CHOICE)
|
||||
self.assertNotEqual(label, CHOICE, path.name)
|
||||
expected = [(cache.get(key) or key).replace('{choice}', label)
|
||||
if '{choice}' in (cache.get(key) or key) else key.replace('{choice}', label)
|
||||
for key in (BEFORE, AFTER)]
|
||||
self.assertEqual(warnings, expected, path.name)
|
||||
# An intentionally absent message key proves fallback independently of shipped catalogs.
|
||||
setattr(module, '_cache', {CHOICE: label})
|
||||
warnings.clear()
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
self.assertEqual(warnings, [BEFORE.replace('{choice}', label),
|
||||
AFTER.replace('{choice}', label)], (path.name, 'synthetic missing'))
|
||||
|
||||
def test_recovery_hint_uses_translated_whole_messages_and_malformed_fallback(self):
|
||||
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
module._language = 'it'
|
||||
for translation in ('Before: «{choice}».', 'Before: label missing.'):
|
||||
module._cache = {CHOICE: 'Recupero', BEFORE: translation,
|
||||
AFTER: 'After: «{choice}».'}
|
||||
warnings = []
|
||||
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
|
||||
msg_warn=warnings.append)
|
||||
hint()
|
||||
hint(after_recovery=True)
|
||||
self.assertEqual(warnings, [
|
||||
('Before: «Recupero».' if '{choice}' in translation
|
||||
else BEFORE.replace('{choice}', 'Recupero')),
|
||||
'After: «Recupero».'])
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Wording contracts through the real metadata parser and exact-match formatter.
|
||||
|
||||
No post-install function or scanner is imported or executed. Node 22.6+ is
|
||||
required, as in test_command_descriptions.py; no npm dependencies are needed.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class OptimizationDescriptionWordingTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Admit only the pure parser and its actual dependencies, not the scanner.
|
||||
path = ROOT / "AppImage/scripts/post_install_versions.py"
|
||||
functions = {"_read_text", "_version_tuple", "parse_post_install_script"}
|
||||
constants = {"_FN_DEF_RE", "_VERSION_RE", "_DESC_RE", "_REGISTER_RE", "_HEREDOC_RE"}
|
||||
nodes: list[ast.stmt] = [node for node in ast.parse(path.read_text(encoding="utf-8")).body
|
||||
if (isinstance(node, ast.FunctionDef) and node.name in functions)
|
||||
or (isinstance(node, ast.Assign) and any(
|
||||
isinstance(target, ast.Name) and target.id in constants
|
||||
for target in node.targets))]
|
||||
if len(nodes) != len(functions) + len(constants):
|
||||
raise AssertionError("Metadata parser seam changed: review its dependencies")
|
||||
scope = {"re": re, "Path": Path}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), scope)
|
||||
cls.metadata = {
|
||||
source: scope["parse_post_install_script"](ROOT / "scripts/post_install" / filename)
|
||||
for source, filename in (("auto", "auto_post_install.sh"),
|
||||
("custom", "customizable_post_install.sh"))
|
||||
}
|
||||
cls.catalogs = {
|
||||
locale: json.loads((ROOT / f"AppImage/messages/{locale}/common.json").read_text(encoding="utf-8"))
|
||||
for locale in ("en", "it")
|
||||
}
|
||||
node = os.environ.get("NODE_BINARY") or shutil.which("node")
|
||||
if not node:
|
||||
raise AssertionError("Install Node.js 22.6+ or set NODE_BINARY")
|
||||
cls.node = node
|
||||
version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout
|
||||
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)\s*", version)
|
||||
if not match or tuple(map(int, match.groups())) < (22, 6, 0):
|
||||
raise AssertionError(f"Node.js 22.6+ required; detected {version.strip()}")
|
||||
# Replace only the JSON import; the formatter body executes unchanged.
|
||||
formatter = (ROOT / "AppImage/lib/optimization-descriptions.ts").read_text(encoding="utf-8")
|
||||
json_import = 'import enMessages from "../messages/en/common.json"'
|
||||
if formatter.count(json_import) != 1:
|
||||
raise AssertionError("Formatter import seam changed")
|
||||
fixture = formatter.replace(json_import, "const enMessages = " + json.dumps(cls.catalogs["en"]))
|
||||
fixture += '''
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
const [key, description, italian, catalogs] = JSON.parse(readFileSync(0, "utf8"));
|
||||
for (const locale of ["en", "it"]) {
|
||||
const calls: string[] = [];
|
||||
const t = (path: string) => {
|
||||
calls.push(path);
|
||||
return path.split(".").reduce((value, part) => value[part], catalogs[locale]);
|
||||
};
|
||||
assert.equal(optimizationDescription(description, t), locale === "en" ? description : italian);
|
||||
assert.deepEqual(calls, [`settings.optimizations.toolDescriptions.${key}`]);
|
||||
calls.length = 0;
|
||||
const unknown = description + " [new server wording]";
|
||||
assert.equal(optimizationDescription(unknown, t), unknown);
|
||||
assert.deepEqual(calls, []);
|
||||
}
|
||||
console.log("PASS actual exact lookup: EN/IT and unknown-description fallback");
|
||||
'''
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="optimization-wording-")
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
cls.fixture = Path(cls.directory.name) / "formatter.mts"
|
||||
cls.fixture.write_text(fixture, encoding="utf-8")
|
||||
|
||||
def assert_description(self, key, source, tool, function, english, italian):
|
||||
for locale, expected in (("en", english), ("it", italian)):
|
||||
with self.subTest(key=key, locale=locale):
|
||||
self.assertEqual(self.catalogs[locale]["settings"]["optimizations"]["toolDescriptions"][key], expected)
|
||||
metadata = self.metadata[source][tool]
|
||||
self.assertEqual(metadata["function"], function)
|
||||
self.assertEqual(metadata["description"], english)
|
||||
result = subprocess.run(
|
||||
[self.node, "--experimental-strip-types", str(self.fixture)],
|
||||
input=json.dumps([key, metadata["description"], italian, self.catalogs]),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("PASS actual exact lookup", result.stdout)
|
||||
|
||||
def test_log2ram_auto_fresh_install_detection_and_update_size(self):
|
||||
self.assert_description(
|
||||
"log2ramAuto", "auto", "log2ram", "install_log2ram_auto",
|
||||
"Fresh installs size Log2RAM from host RAM and ask for confirmation if storage is not detected as SSD. Updates preserve the configured size.",
|
||||
"Alla prima installazione, dimensiona Log2RAM in base alla RAM dell'host e chiede conferma se il dispositivo di archiviazione non viene rilevato come SSD. Gli aggiornamenti mantengono la dimensione configurata.")
|
||||
|
||||
def test_kexec_prepares_unit_and_alias_not_hotkey(self):
|
||||
self.assert_description(
|
||||
"kexec", "custom", "kexec", "enable_kexec",
|
||||
"Install kexec-tools and prepare a systemd unit plus the reboot-quick alias for fast reboots that skip BIOS/POST.",
|
||||
"Installa kexec-tools e prepara un'unità systemd e l'alias reboot-quick per riavvii rapidi che saltano BIOS/POST.")
|
||||
|
||||
def test_amd_only_claims_idle_parameter_and_kvm_module_options(self):
|
||||
self.assert_description(
|
||||
"amd", "custom", "amd_fixes", "apply_amd_fixes",
|
||||
"Apply idle=nomwait and KVM module options on AMD EPYC/Ryzen hosts.",
|
||||
"Applica idle=nomwait e opzioni del modulo KVM sugli host AMD EPYC/Ryzen.")
|
||||
|
||||
def test_guest_agent_only_lists_supported_environments_and_skip(self):
|
||||
self.assert_description(
|
||||
"guestAgent", "custom", "guest_agent", "install_guest_agent",
|
||||
"Detect QEMU/KVM, VMware or VirtualBox and install the matching guest agent; skip other environments.",
|
||||
"Rileva QEMU/KVM, VMware o VirtualBox e installa il guest agent corrispondente; salta gli altri ambienti.")
|
||||
|
||||
def test_backup_describes_global_defaults_without_speed_guarantee(self):
|
||||
self.assert_description(
|
||||
"backupLimits", "custom", "vzdump_speed", "optimize_vzdump",
|
||||
"Set global vzdump defaults to bwlimit: 0 and ionice: 5. This does not guarantee faster backups.",
|
||||
"Imposta i valori predefiniti globali di vzdump su bwlimit: 0 e ionice: 5. Questo non garantisce backup più veloci.")
|
||||
|
||||
def test_log2ram_custom_choices_and_update_preservation(self):
|
||||
self.assert_description(
|
||||
"log2ramCustom", "custom", "log2ram", "configure_log2ram",
|
||||
"Install Log2RAM with a chosen size, sync interval and optional autosync. Updates preserve the size and autosync choice.",
|
||||
"Installa Log2RAM con dimensione e intervallo di sincronizzazione a scelta e autosync facoltativo. Gli aggiornamenti mantengono la dimensione e la scelta sull'autosync.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""A preview or a warning promises only what the code always does."""
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
|
||||
def oci_menu_texts():
|
||||
tree = ast.parse((ROOT / 'oci/src/proxmenux_oci/management.py').read_text())
|
||||
return [node.args[0].value for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and getattr(node.func, 'id', '') == 'translate'
|
||||
and node.args and isinstance(node.args[0], ast.Constant)]
|
||||
|
||||
|
||||
class OutcomePromises(TestCase):
|
||||
def test_a_failure_is_not_promised_to_end_in_a_restore(self):
|
||||
texts = oci_menu_texts()
|
||||
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
|
||||
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
|
||||
self.assertEqual(len(previews), 2) # Coordinated stack update/recreation previews.
|
||||
update = next(text for text in texts if text.startswith('If a newer application image is available'))
|
||||
self.assertIn('attempts to restore the previous installation', update)
|
||||
self.assertIn('If the operation remains incomplete', update)
|
||||
self.assertIn('choose "Recover the previous installation"', update)
|
||||
|
||||
def test_the_recovery_preview_says_when_each_step_happens(self):
|
||||
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
|
||||
self.assertIn('If the container had already been changed', preview)
|
||||
self.assertIn('when the cleanup succeeds', preview)
|
||||
|
||||
def test_a_rclone_mount_is_not_listed_as_a_registration_problem(self):
|
||||
prompt = next(text for text in oci_menu_texts() if text.startswith('Until they are registered again'))
|
||||
self.assertIn('does not prevent it', prompt)
|
||||
self.assertIn('not started automatically', prompt)
|
||||
|
||||
def test_an_empty_retention_is_the_one_of_the_storage(self):
|
||||
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
|
||||
self.assertIn('Leave empty to use the retention of the storage.', menu)
|
||||
self.assertNotIn('keep every backup', menu)
|
||||
|
||||
def test_host_backups_are_said_to_follow_the_storage(self):
|
||||
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
|
||||
self.assertNotIn('stop running', menu)
|
||||
self.assertIn('Another job that writes to the same storage still starts them.', menu)
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
|
||||
self.assertNotIn('stop running', english['deleteAttached'] + english['storageAttached'])
|
||||
self.assertIn('scheduled runs', english['disableBody'])
|
||||
for locale in LOCALES:
|
||||
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
|
||||
for key in ('deleteAttached', 'storageAttached'):
|
||||
self.assertEqual(texts[key].count('{ids}'), 1, f'{locale} {key}')
|
||||
self.assertEqual(texts['disableBody'].count('. '), 1, f'{locale} disableBody')
|
||||
|
||||
def test_the_host_restore_notice_reports_tasks_and_does_not_call_the_node_ready(self):
|
||||
for locale in LOCALES:
|
||||
body = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['runtime']['notifications']['templates']['system_restore_completed']['body']
|
||||
self.assertEqual(body.split('\n')[-1], '{warnings_block}', locale)
|
||||
self.assertEqual(body.count('\n'), 7, locale)
|
||||
|
||||
def test_the_watchdog_failure_covers_an_application_that_did_not_start(self):
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']['oci_watchdog_failed']
|
||||
self.assertNotIn('keeps stopping', english['title'] + english['body'] + english['label'])
|
||||
self.assertIn('did not start', english['body'])
|
||||
|
||||
def test_other_wording_that_promised_too_much(self):
|
||||
texts = oci_menu_texts()
|
||||
self.assertIn('Private network kept, because other guests still use it:', texts)
|
||||
recreation = (ROOT / 'oci/src/proxmenux_oci/stack_recreation.py').read_text()
|
||||
self.assertNotIn('if anything fails', recreation)
|
||||
self.assertIn('a stopped one is left stopped', recreation)
|
||||
for script in ('scripts/post_install/customizable_post_install.sh', 'scripts/post_install/uninstall-tools.sh'):
|
||||
self.assertNotIn('leaving it unchanged', (ROOT / script).read_text(), script)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""PBS CLI language and no-effect boundaries using real functions, isolated files."""
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def function(path, name):
|
||||
text = (ROOT/path).read_text()
|
||||
start = text.index(name+'() {')
|
||||
return text[start:text.index('\n}\n', start)+3]
|
||||
|
||||
|
||||
def repository(components, suite='trixie'):
|
||||
return dict(Types=['deb'], URIs=['https://credential.invalid/private'], Suites=[suite],
|
||||
Components=components.split(), Enabled=True)
|
||||
|
||||
|
||||
class RepositoryCliTests(unittest.TestCase):
|
||||
def run_policy(self, case, language='sk', mode='plan', malformed=None, product='pbs'):
|
||||
spec = importlib.util.spec_from_file_location('policy_fixture', ROOT/'scripts/global/repository_policy.py')
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
state = dict(files=[dict(path='/etc/apt/sources.list.d/pbs.sources', repositories=[repository('pbs-enterprise')]),
|
||||
dict(path='/etc/apt/sources.list.d/debian.sources', repositories=[repository('main')])],
|
||||
errors=[], digest='fixture', **{'standard-repos':[{'handle':'no-subscription'}]})
|
||||
status='notfound'; calls=[]
|
||||
if case=='inventory': state['errors']=['PRIVATE RAW DIAGNOSTIC']
|
||||
if case=='status': status='unknown'
|
||||
if case=='suite': state['files'][0]['repositories'][0]['Suites']=['bookworm']
|
||||
if case=='active': status='active'; state['files']=state['files'][1:]
|
||||
if case=='debian': state['files']=state['files'][:1]
|
||||
if case=='mixed': state['files'][0]['repositories'][0]['Components'].append('main')
|
||||
if case=='handle': state['standard-repos']=[]
|
||||
if case=='preserve': state['files'][0]['repositories'][0]['Components']=['pbs-no-subscription']
|
||||
original=copy.deepcopy(state)
|
||||
def run(args):
|
||||
calls.append(args)
|
||||
if args[0]=='proxmox-backup-manager': return json.dumps(dict(status=status, serverid='PRIVATE SERVER ID'))
|
||||
if 'create' in args:
|
||||
state['files'][0]['repositories'][0]['Enabled']=False
|
||||
if case=='partial': state['errors']=['PRIVATE RAW DIAGNOSTIC']
|
||||
return ''
|
||||
if 'set' in args:
|
||||
state['files'].append(dict(path='/etc/apt/sources.list.d/public.sources',repositories=[repository('pbs-no-subscription')]))
|
||||
return ''
|
||||
return json.dumps(state)
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-policy-i18n-') as folder:
|
||||
module.BASE=Path(folder)
|
||||
(module.BASE/'lang').mkdir()
|
||||
(module.BASE/'config.json').write_text(json.dumps({'language':language}))
|
||||
catalog=(ROOT/'lang/sk.json').read_text() if malformed is None else malformed
|
||||
(module.BASE/'lang/sk.json').write_text(catalog)
|
||||
stdout,stderr=io.StringIO(),io.StringIO()
|
||||
suite='sid' if case=='unsupported' else 'trixie'
|
||||
with patch.object(module,'run',side_effect=run), patch.object(module.sys,'argv',['repository_policy.py',mode,suite,product]), contextlib.redirect_stdout(stdout),contextlib.redirect_stderr(stderr):
|
||||
result=module.main()
|
||||
return result,stdout.getvalue(),stderr.getvalue(),calls,state,original
|
||||
|
||||
def test_pbs_errors_are_localized_without_changing_decisions_or_exposing_raw_data(self):
|
||||
expectations={'inventory':'Zoznam repozitárov APT', 'status':'Stav predplatného',
|
||||
'suite':'iné vydanie', 'active':'aktívne predplatné', 'debian':'základný repozitár Debianu',
|
||||
'mixed':'spoločný záznam APT', 'handle':'no-subscription', 'unsupported':'Nepodporované vydanie',
|
||||
'partial':'stav repozitárov je neznámy'}
|
||||
for case,expected in expectations.items():
|
||||
with self.subTest(case=case):
|
||||
result,out,error,calls,state,original=self.run_policy(case,mode='apply' if case=='partial' else 'plan')
|
||||
self.assertEqual(result,1); self.assertEqual(out,''); self.assertIn(expected,error)
|
||||
self.assertNotIn('PRIVATE',error); self.assertNotIn('credential.invalid',error)
|
||||
writes=[c for c in calls if 'create' in c or 'set' in c]
|
||||
if case=='partial':
|
||||
self.assertEqual(len(writes),1)
|
||||
self.assertFalse(state['files'][0]['repositories'][0]['Enabled'])
|
||||
self.assertIn('Automatický návrat',error)
|
||||
else:
|
||||
self.assertEqual(writes,[]); self.assertEqual(state,original)
|
||||
if case=='unsupported': self.assertEqual(calls,[])
|
||||
|
||||
def test_english_error_and_stdout_tokens_remain_compatible(self):
|
||||
en=self.run_policy('unsupported',language='en')
|
||||
self.assertEqual(en[2],'Unsupported Proxmox suite; no repository changed.\n')
|
||||
pve=self.run_policy('unsupported',product='pve')
|
||||
self.assertEqual(pve[2],en[2])
|
||||
for mode,expected in [('plan','offer\n'),('apply','changed\n')]:
|
||||
result,out,error,*_=self.run_policy('offer',mode=mode)
|
||||
self.assertEqual((result,out,error),(0,expected,''))
|
||||
result,out,error,calls,*_=self.run_policy('preserve',mode='apply')
|
||||
self.assertEqual((result,out,error),(0,'preserve\n',''))
|
||||
self.assertFalse(any(c[0]=='proxmox-backup-manager' or 'set' in c or 'create' in c for c in calls))
|
||||
|
||||
def test_bad_or_missing_translation_falls_back_to_original_english(self):
|
||||
for catalog in ('{}','not json','[]',json.dumps({'Unsupported Proxmox suite; no repository changed.':42}),
|
||||
json.dumps({'Unsupported Proxmox suite; no repository changed.':'{unknown}'})):
|
||||
with self.subTest(catalog=catalog):
|
||||
result,out,error,calls,*_=self.run_policy('unsupported',malformed=catalog)
|
||||
self.assertEqual(result,1);self.assertEqual(out,'');self.assertEqual(calls,[])
|
||||
self.assertEqual(error,'Unsupported Proxmox suite; no repository changed.\n')
|
||||
|
||||
def test_translation_cannot_drop_actual_repository_details(self):
|
||||
spec=importlib.util.spec_from_file_location('policy_format_fixture',ROOT/'scripts/global/repository_policy.py')
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-policy-format-') as folder:
|
||||
module.BASE=Path(folder);(module.BASE/'lang').mkdir()
|
||||
(module.BASE/'config.json').write_text('{"language":"sk"}')
|
||||
error=module.PolicyError('Inspect {ui}; no repository changed.',ui='Administration > Repositories')
|
||||
for invalid in ('Skontrolujte repozitáre.','{ui.__class__}','{other}','{ui:.0}','{ui!r}'):
|
||||
(module.BASE/'lang/sk.json').write_text(json.dumps({error.template:invalid}))
|
||||
self.assertEqual(module.cli_error(error,'pbs'),str(error))
|
||||
for invalid in ('not json','[]','{"language":"../../private"}'):
|
||||
(module.BASE/'config.json').write_text(invalid)
|
||||
self.assertEqual(module.cli_error(error,'pbs'),str(error))
|
||||
|
||||
|
||||
UPDATE_HARNESS=r'''
|
||||
BASE_DIR="$FIXTURE"
|
||||
LANG_DIR="$REPO/lang"
|
||||
SCRIPT_TITLE='fixture'
|
||||
TAB='' BGN='' CL='' GN='' BL=''
|
||||
mkdir -p "$FIXTURE/log" "$FIXTURE/tmp" "$FIXTURE/boot" "$FIXTURE/run"
|
||||
: > "$FIXTURE/calls"
|
||||
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
|
||||
pmx_journal_context() { :; }
|
||||
pmx_is_pbs() { [[ "$CASE" != pve ]]; }
|
||||
pmx_platform_field() { [[ "$1" != supported ]] && printf '%s' "$SUITE" || printf true; }
|
||||
ensure_pbs_repositories() { record repository-plan; return 0; }
|
||||
dpkg-query() { [[ "$SUITE" == bookworm ]] && printf 3.4.0 || printf 4.0.1; }
|
||||
apt-cache() { record apt-cache "$@"; printf ' Candidate: 4.0.2\n'; }
|
||||
df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted\nfixture 10000 1000 %s 10%% /\n' "$([[ "$CASE" == disk ]] && echo 512000 || echo 4096000)"; }
|
||||
apt-get() { record apt-get "$@"; [[ "$CASE" != refresh ]]; }
|
||||
apt() {
|
||||
record apt "$@"
|
||||
if [[ "$*" == 'list --upgradable' ]]; then
|
||||
printf 'Listing...\n'
|
||||
[[ "$CASE" == current ]] || printf 'fixture/trixie 1.2 amd64 [upgradable from: 1.1]\n'
|
||||
return 0
|
||||
fi
|
||||
[[ "$CASE" != failure ]]
|
||||
}
|
||||
fixture_env() { "$@"; }
|
||||
APT_ENV='fixture_env'
|
||||
pmx_format_upgradable() { cat; }
|
||||
pmx_record_execution() { record journal "$1"; }
|
||||
pmx_dpkg_changes_since() { :; }
|
||||
msg_error() { printf '%s\n' "$*"; }
|
||||
msg_ok() { printf '%s\n' "$*"; }
|
||||
msg_info2() { printf '%s\n' "$*"; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
msg_title() { :; }
|
||||
clear() { :; }
|
||||
show_proxmenux_logo() { :; }
|
||||
whiptail() { printf '%s\n' "$*" >> "$FIXTURE/dialog"; [[ "$CASE" != cancel ]]; }
|
||||
uname() { printf old-kernel; }
|
||||
touch "$FIXTURE/boot/vmlinuz-new-kernel"
|
||||
'''
|
||||
|
||||
|
||||
class UpdateCliTests(unittest.TestCase):
|
||||
def run_update(self,case,language='sk',suite='trixie'):
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-update-i18n-') as folder:
|
||||
body=function('scripts/global/update-pbs.sh','update_pbs')
|
||||
for old,new in [('/var/log/',folder+'/log/'),('/tmp/proxmenux_screen_capture_',folder+'/tmp/proxmenux_screen_capture_'),('/var/run/',folder+'/run/'),('/boot/',folder+'/boot/')]: body=body.replace(old,new)
|
||||
script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+function('scripts/global/common-functions.sh','pmx_confirm_update')+body+'\nupdate_pbs\n'
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite})
|
||||
files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()}
|
||||
return result,files
|
||||
|
||||
def test_pve_guard_cannot_reach_repository_or_apt_calls(self):
|
||||
result,files=self.run_update('pve')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertEqual(files['calls'],'')
|
||||
self.assertFalse(any(name.startswith('log/') for name in files))
|
||||
|
||||
def test_disk_and_upgrade_errors_keep_their_actual_parameters(self):
|
||||
result,files=self.run_update('disk')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertIn('Nedostatok miesta na disku. Dostupné:',result.stdout)
|
||||
self.assertIn('500MB',result.stdout)
|
||||
self.assertNotIn('apt',files['calls'])
|
||||
result,files=self.run_update('failure')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertIn('Aktualizácia systému zlyhala. Skontrolujte denník:',result.stdout)
|
||||
self.assertIn('/log/proxmox-update-',result.stdout)
|
||||
self.assertNotIn('autoremove',files['calls'])
|
||||
|
||||
def test_update_status_cancel_refresh_error_and_reboot_notice(self):
|
||||
for suite in ('bookworm','trixie'):
|
||||
for case in ('current','cancel','refresh','success'):
|
||||
with self.subTest(suite=suite,case=case):
|
||||
result,files=self.run_update(case,suite=suite)
|
||||
self.assertEqual(result.returncode,1 if case=='refresh' else 0,result.stderr)
|
||||
if case!='success': self.assertNotIn('full-upgrade',files['calls'])
|
||||
if case=='success': self.assertIn('reštartujte server',result.stdout)
|
||||
if case in ('current','cancel'): self.assertIn('Aktuálna verzia PBS',files['dialog'])
|
||||
|
||||
|
||||
class DialogTests(unittest.TestCase):
|
||||
def test_automated_confirmation_and_banner_cancel_do_not_run_actions(self):
|
||||
cases=[('scripts/menus/menu_post_install.sh','confirm_automated_script',
|
||||
['repozitár no-subscription','upozornenie na predplatné','pamäť','jadro','sieť']),
|
||||
('scripts/post_install/auto_post_install.sh','remove_subscription_banner',
|
||||
['Proxmox Backup Server – odstránenie upozornenia na predplatné'])]
|
||||
for path,name,expected in cases:
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-dialog-') as folder:
|
||||
script=function('scripts/utils.sh','translate')+function(path,name)+r'''
|
||||
PMX_ON_PBS=true
|
||||
LOCAL_SCRIPTS='/fixture/scripts'
|
||||
clear() { :; }; sleep() { :; }; stop_spinner() { :; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
bash() { printf forbidden >> "$CAPTURE/actions"; }
|
||||
register_tool() { printf forbidden >> "$CAPTURE/actions"; }
|
||||
dialog() { printf '%s\n' "$*" >> "$CAPTURE/dialog"; return 1; }
|
||||
whiptail() { dialog "$@"; }
|
||||
'''+name+'\n'
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'CAPTURE':folder})
|
||||
self.assertEqual(result.returncode,1 if name=='remove_subscription_banner' else 0,result.stderr)
|
||||
dialog=(Path(folder)/'dialog').read_text()
|
||||
for text in expected:self.assertIn(text,dialog)
|
||||
self.assertFalse((Path(folder)/'actions').exists())
|
||||
if name=='remove_subscription_banner':self.assertIn('bolo zrušené používateľom',result.stdout)
|
||||
|
||||
def test_community_labels_preserve_dispatch_names_and_translate_display(self):
|
||||
script=function('scripts/utils.sh','translate')+function('scripts/menus/menu_post_install.sh','post_install_label')+'\npost_install_label "$1"\n'
|
||||
for text,expected in [('Proxmox Backup Server Post Install','nastavenie po inštalácii'),
|
||||
('Proxmox Backup Server Microcode','mikrokód procesora')]:
|
||||
result=subprocess.run(['bash','-c',script,'bash',text],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang')})
|
||||
self.assertEqual(result.returncode,0,result.stderr);self.assertIn(expected,result.stdout)
|
||||
|
||||
def test_failed_apt_hook_check_removes_only_fixture_hook_with_localized_warning(self):
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-hook-') as folder:
|
||||
script=function('scripts/utils.sh','translate')+function('scripts/global/remove-banner-pve-v3.sh','create_apt_hook')+r'''
|
||||
apt-config() { return 1; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
create_apt_hook
|
||||
'''
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'APT_HOOK':folder+'/hook'})
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
self.assertIn('Nesprávna syntax hooku APT',result.stdout)
|
||||
self.assertFalse((Path(folder)/'hook').exists())
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Offline actual scheduler gates; never source the administrative script."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCHEDULER = ROOT / 'scripts/backup_restore/backup_scheduler.sh'
|
||||
MESSAGES = {
|
||||
'pbs': 'No matching PVE vzdump jobs were found for the PBS backend.',
|
||||
'local': 'No matching PVE vzdump jobs were found for the local archive backend.',
|
||||
}
|
||||
HINT = 'Create one first in Datacenter → Backup, then return here to attach.'
|
||||
|
||||
|
||||
def function(text, name):
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}', start) + 2] + '\n'
|
||||
|
||||
|
||||
def consumers():
|
||||
text = SCHEDULER.read_text()
|
||||
attached = function(text, '_create_job_attached').split(' local pve_prune\n')[0] + '}\n'
|
||||
# Only the backend/attach selection gate, not ID/config writes or scheduling.
|
||||
new = function(text, '_create_job')
|
||||
new = '_create_job() {\n local id=fixture backend on_calendar\n' + new[new.index(' backend=$(dialog'):new.index(' on_calendar=$(dialog')]
|
||||
new += ' return 42\n}\n'
|
||||
return attached + new
|
||||
|
||||
|
||||
def fixture(directory, backend='pbs', consumer='attached', rows='', second=None,
|
||||
locale='en', catalog=None, real_dialog=None):
|
||||
directory = Path(directory)
|
||||
(directory / 'xx.json').write_text(json.dumps(catalog or {}))
|
||||
(directory / 'rows').write_text(rows)
|
||||
(directory / 'second').write_text(rows if second is None else second)
|
||||
bindir = directory / 'bin'
|
||||
bindir.mkdir(exist_ok=True)
|
||||
# Only jq is executable via PATH; wrapper validates all lookup arguments.
|
||||
(bindir / 'jq').symlink_to('/usr/bin/jq') if not (bindir / 'jq').exists() else None
|
||||
translation = function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
|
||||
filter_code = function((ROOT / 'scripts/backup_restore/lib_host_backup_common.sh').read_text(), 'hb_pve_list_vzdump_jobs_for_backend')
|
||||
preamble = f'''PATH={shlex.quote(str(bindir))}
|
||||
LANG_DIR={shlex.quote(str(directory))}
|
||||
LANGUAGE={shlex.quote(locale)}
|
||||
FIXTURE={shlex.quote(str(directory))}
|
||||
BACKEND={backend}
|
||||
HB_UI_MENU_H=20 HB_UI_MENU_W=84 HB_UI_MENU_LIST=10
|
||||
command_not_found_handle() {{ printf 'FORBIDDEN:%s\\n' "$*" >> "$FIXTURE/errors"; return 99; }}
|
||||
jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // .[($text | gsub("\\\\\\\\n"; "\\n"))] // empty' && "$6" == "$FIXTURE/xx.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }}
|
||||
head() {{ [[ "$*" == '-1' ]] || return 99; local first; IFS= read -r first; printf '%s\\n' "$first"; while IFS= read -r first; do :; done; }}
|
||||
hb_pve_list_vzdump_jobs() {{
|
||||
local file="$FIXTURE/rows" line
|
||||
[[ -f "$FIXTURE/read" ]] && file="$FIXTURE/second"
|
||||
printf read >> "$FIXTURE/read"
|
||||
while IFS= read -r line; do printf '%s\\n' "$line"; done < "$file"
|
||||
}}
|
||||
dialog() {{
|
||||
printf '%s\\0' "$@" >> "$FIXTURE/dialogs"
|
||||
local title='' kind='' message='' previous='' arg
|
||||
for arg in "$@"; do
|
||||
[[ "$previous" == --title ]] && title="$arg"
|
||||
[[ "$previous" == --msgbox || "$previous" == --menu ]] && message="$arg"
|
||||
[[ "$arg" == --msgbox || "$arg" == --menu ]] && kind="$arg"
|
||||
previous="$arg"
|
||||
done
|
||||
if [[ "$kind" == --msgbox ]]; then
|
||||
printf '%s' "$message" > "$FIXTURE/message"
|
||||
{'command ' + shlex.quote(real_dialog) + ' "$@"' if real_dialog else ':'}
|
||||
return 0
|
||||
fi
|
||||
case "$title" in
|
||||
"$(translate 'Backend')") printf '%s' "$BACKEND" >&2 ;;
|
||||
"$(translate 'How to schedule')") printf attach >&2 ;;
|
||||
"$(translate 'Pick PVE vzdump job')") return 1 ;;
|
||||
*) printf 'unexpected dialog:%s' "$title" >> "$FIXTURE/errors"; return 99 ;;
|
||||
esac
|
||||
}}
|
||||
'''
|
||||
return preamble + translation + filter_code + consumers() + f'\nif _create_job{"_attached fixture " + backend if consumer == "attached" else ""}; then rc=0; else rc=$?; fi\nprintf "%s" "$rc" > "$FIXTURE/status"\n'
|
||||
|
||||
|
||||
def run_case(**kwargs):
|
||||
with tempfile.TemporaryDirectory(prefix='scheduler-') as directory:
|
||||
code = fixture(directory, **kwargs)
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', code],
|
||||
env={'PATH': '/nonexistent', 'LC_ALL': 'C.UTF-8'}, capture_output=True, text=True)
|
||||
if result.returncode or result.stderr:
|
||||
raise AssertionError((result.returncode, result.stderr))
|
||||
p = Path(directory)
|
||||
if (p / 'errors').exists():
|
||||
raise AssertionError((p / 'errors').read_text())
|
||||
return {name: (p / name).read_text() if (p / name).exists() else ''
|
||||
for name in ('message', 'status', 'dialogs', 'read')}
|
||||
|
||||
|
||||
class SchedulerMessages(unittest.TestCase):
|
||||
def test_new_whole_message_and_separate_hint(self):
|
||||
for backend in MESSAGES:
|
||||
with self.subTest(backend=backend):
|
||||
result = run_case(backend=backend, consumer='new')
|
||||
self.assertEqual(result['message'], MESSAGES[backend] + '\n\n' + HINT)
|
||||
self.assertEqual(result['status'], '1')
|
||||
|
||||
def test_lookup_cache_modes_at_both_consumers(self):
|
||||
shipped = json.loads((ROOT / 'lang/it.json').read_text())
|
||||
for backend, message in MESSAGES.items():
|
||||
for consumer in ('attached', 'new'):
|
||||
for locale, catalog, expected in (
|
||||
('en', {message: 'ignored'}, message),
|
||||
('missing', {}, message),
|
||||
('xx', {'unrelated': 'unrelated'}, message),
|
||||
('xx', shipped, shipped.get(message, message)),
|
||||
('xx', {message: 'SYNTHETIC reordered backend: ' + backend},
|
||||
'SYNTHETIC reordered backend: ' + backend),
|
||||
):
|
||||
with self.subTest(backend=backend, consumer=consumer, locale=locale, expected=expected):
|
||||
result = run_case(backend=backend, consumer=consumer, locale=locale, catalog=catalog)
|
||||
suffix = '\n\n' + catalog.get(HINT, HINT) if consumer == 'new' else ''
|
||||
self.assertEqual(result['message'], expected + suffix)
|
||||
self.assertEqual(result['status'], '1')
|
||||
|
||||
def test_nonempty_menus_disabled_and_second_read_empty(self):
|
||||
for backend, storage in (('pbs', 'pbs'), ('local', 'nfs')):
|
||||
for count in (1, 3):
|
||||
rows = ''.join(f'job{i}\tstore{i}\t{storage}\tdaily\t-\t0\n' for i in range(count))
|
||||
for consumer in ('attached', 'new'):
|
||||
with self.subTest(backend=backend, count=count, consumer=consumer):
|
||||
result = run_case(backend=backend, consumer=consumer, rows=rows)
|
||||
self.assertEqual(result['message'], '')
|
||||
self.assertEqual(result['status'], '1') # cancel at actual parent picker
|
||||
for i in range(count):
|
||||
self.assertIn(f'job{i} · store{i} · daily (disabled)', result['dialogs'])
|
||||
result = run_case(backend=backend, consumer='new', rows=rows, second='')
|
||||
self.assertEqual(result['message'], MESSAGES[backend])
|
||||
self.assertEqual(result['read'], 'readread')
|
||||
self.assertEqual(result['status'], '1')
|
||||
|
||||
def test_actual_filter_types_and_enabled_states(self):
|
||||
for backend in MESSAGES:
|
||||
for storage in ('pbs', 'dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs', 'rbd', '-'):
|
||||
for enabled in ('0', '1'):
|
||||
with self.subTest(backend=backend, storage=storage, enabled=enabled):
|
||||
result = run_case(backend=backend, rows=f'job\tstore\t{storage}\tdaily\t-\t{enabled}\n')
|
||||
matches = storage == 'pbs' if backend == 'pbs' else storage in ('dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs')
|
||||
self.assertEqual(result['message'], '' if matches else MESSAGES[backend])
|
||||
if matches:
|
||||
self.assertEqual('(disabled)' in result['dialogs'], enabled == '0')
|
||||
|
||||
def test_borg_excludes_attach_gate(self):
|
||||
result = run_case(backend='borg', consumer='new')
|
||||
self.assertEqual(result['status'], '42') # stopped before real schedule/config work
|
||||
self.assertEqual(result['message'], '')
|
||||
self.assertEqual(result['read'], '')
|
||||
self.assertNotIn('How to schedule', result['dialogs'])
|
||||
|
||||
def test_actual_offline_extractor_discovers_whole_messages(self):
|
||||
spec = importlib.util.spec_from_file_location('scheduler_cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
discovered = module.extract_translate_texts(ROOT / 'scripts')
|
||||
for message in MESSAGES.values():
|
||||
self.assertIn(message, discovered)
|
||||
|
||||
def test_attached_whole_message(self):
|
||||
for backend in MESSAGES:
|
||||
with self.subTest(backend=backend):
|
||||
result = run_case(backend=backend)
|
||||
self.assertEqual(result['message'], MESSAGES[backend])
|
||||
self.assertEqual(result['status'], '1')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Offline acquisition fixtures; never source or run the administrative script.
|
||||
|
||||
Requires Bash, jq, curl, wget, gzip and unzip. Only bounded native functions
|
||||
are loaded; HTTP clients are confined to a loopback fixture server. No host
|
||||
package manager, Proxmox command, installer or VM is executed.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
import gzip
|
||||
import http.server
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import warnings
|
||||
import zipfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SOURCE = ROOT / "scripts/vm/synology.sh"
|
||||
RAW = b"tiny offline loader fixture\x00\x01\x02" * 8
|
||||
PLAIN = "alpine-redpill.v1.4.5.6.m-shell.img.gz"
|
||||
VARIANTS = [
|
||||
"alpine-redpill.v1.4.5.6.m-shell-5GB.img.gz",
|
||||
"alpine-redpill.v1.4.5.6.xtcrp.img.gz",
|
||||
"alpine-redpill.v1.4.5.6.m-shell.vmdk.gz",
|
||||
"alpine-redpill.v1.4.5.6.xtcrp-5GB.img.gz",
|
||||
]
|
||||
|
||||
|
||||
def native_function(name):
|
||||
match = re.search(
|
||||
rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}",
|
||||
SOURCE.read_text(), re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
raise AssertionError(f"Native function not found: {name}")
|
||||
return match.group()
|
||||
|
||||
|
||||
def asset(name):
|
||||
return {"name": name, "browser_download_url": f"https://fixtures.invalid/{name}"}
|
||||
|
||||
|
||||
def zip_bytes(member, payload=RAW, extras=()):
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(member, payload)
|
||||
for name, content in extras:
|
||||
archive.writestr(name, content)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def fixture_server(metadata, payload, *, metadata_status=200, asset_status=200,
|
||||
partial=False):
|
||||
requests = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
requests.append(self.path)
|
||||
is_metadata = self.path.startswith("/metadata/")
|
||||
body = metadata if is_metadata else payload
|
||||
status = metadata_status if is_metadata else asset_status
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(body) + (
|
||||
37 if partial and not is_metadata else 0)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
self.close_connection = True
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever,
|
||||
kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}", requests
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
class SynologyLoaderTests(unittest.TestCase):
|
||||
def run_loader(self, loader="tinycore", *, names=None, metadata=None,
|
||||
payload=None, pretty=True, before="", invoke="download_loader",
|
||||
functions=(), **server_options):
|
||||
if names is None:
|
||||
names = [PLAIN, *VARIANTS]
|
||||
if metadata is None:
|
||||
metadata = json.dumps({"assets": [asset(name) for name in names]},
|
||||
indent=2 if pretty else None).encode()
|
||||
if payload is None:
|
||||
payload = gzip.compress(RAW)
|
||||
expected = {"arc": "arc.img", "redpill": "rr.img", "tinycore": "tinycore.img"}[loader]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
images = base / "images with spaces"
|
||||
images.mkdir()
|
||||
previous = images / expected
|
||||
previous.write_bytes(b"previous verified image")
|
||||
bin_dir = base / "bin"
|
||||
bin_dir.mkdir()
|
||||
for command in ("bash", "jq", "curl", "wget", "gzip", "unzip"):
|
||||
self.assertIsNotNone(shutil.which(command), f"Install test prerequisite: {command}")
|
||||
with fixture_server(metadata, payload, **server_options) as (url, requests):
|
||||
for command in ("curl", "wget"):
|
||||
real = shutil.which(command)
|
||||
client_options = ["--no-config", "--tries=1"] if command == "wget" else ["-q"]
|
||||
wrapper = bin_dir / command
|
||||
wrapper.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import os, sys\n"
|
||||
"args = sys.argv[1:]\n"
|
||||
"skip = False\n"
|
||||
"for arg in args:\n"
|
||||
" if skip:\n"
|
||||
" skip = False\n"
|
||||
" continue\n"
|
||||
" if arg in ('-O', '--connect-timeout', '--max-time'):\n"
|
||||
" skip = True\n"
|
||||
" elif not arg.startswith('-') and not arg.startswith(('https://api.github.com/repos/', 'https://fixtures.invalid/')):\n"
|
||||
" sys.exit('Forbidden non-fixture argument: ' + arg)\n"
|
||||
"for i, arg in enumerate(args):\n"
|
||||
" if arg.startswith('https://api.github.com/repos/'):\n"
|
||||
f" args[i] = {url!r} + '/metadata/' + arg.split('/repos/', 1)[1]\n"
|
||||
" elif arg.startswith('https://fixtures.invalid/'):\n"
|
||||
f" args[i] = {url!r} + '/asset/' + arg.rsplit('/', 1)[1]\n"
|
||||
" elif '://' in arg:\n"
|
||||
" sys.exit('Forbidden non-fixture URL: ' + arg)\n"
|
||||
"os.environ['no_proxy'] = os.environ['NO_PROXY'] = '127.0.0.1'\n"
|
||||
f"os.execv({real!r}, [{real!r}, *{client_options!r}, *args])\n"
|
||||
)
|
||||
wrapper.chmod(0o755)
|
||||
# Administrative boundaries are fail-closed even if a test regresses.
|
||||
for command in ("apt-get", "qm", "pct", "pvesm", "pvesh"):
|
||||
wrapper = bin_dir / command
|
||||
wrapper.write_text("#!/bin/bash\nprintf 'FORBIDDEN %s\\n' \"$0\" >&2\nexit 97\n")
|
||||
wrapper.chmod(0o755)
|
||||
definitions = "\n".join(native_function(name) for name in
|
||||
dict.fromkeys(("download_loader", *functions)))
|
||||
script = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_info() { printf 'INFO:%s\n' "$*"; }
|
||||
msg_ok() { printf 'OK:%s\n' "$*"; }
|
||||
msg_error() { printf 'ERROR:%s\n' "$*"; }
|
||||
msg_warn() { printf 'WARN:%s\n' "$*"; }
|
||||
stop_spinner() { :; }
|
||||
sleep() { :; }
|
||||
select_loader() { printf 'UNEXPECTED_RECURSION\n'; return 91; }
|
||||
'''
|
||||
script += definitions + "\n" + before + "\n"
|
||||
script += invoke + r'''
|
||||
result=$?
|
||||
printf 'RESULT:%s\nFILE:%s\nLOADER_FILE:%s\nLOADER_TYPE:%s\nLOADER_NAME:%s\n' "$result" "$FILE" "$LOADER_FILE" "$LOADER_TYPE" "$LOADER_NAME"
|
||||
exit "$result"
|
||||
'''
|
||||
environment = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}",
|
||||
IMAGES_DIR=str(images), LOADER_TYPE=loader,
|
||||
LOADER_NAME="fixture loader", FILE="stale.img",
|
||||
LOADER_FILE="/stale.img")
|
||||
result = subprocess.run(["bash", "-c", script], env=environment,
|
||||
cwd=base, capture_output=True, text=True, timeout=15)
|
||||
state = dict(re.findall(r"^(RESULT|FILE|LOADER_FILE|LOADER_TYPE|LOADER_NAME):(.*)$", result.stdout, re.MULTILINE))
|
||||
return result, state, previous.read_bytes(), list(requests), sorted(
|
||||
str(path.relative_to(images)) for path in images.rglob("*"))
|
||||
|
||||
def test_plain_alpine_selection_ignores_competing_variants(self):
|
||||
for pretty, names in ((True, [*VARIANTS, PLAIN]),
|
||||
(False, [VARIANTS[2], PLAIN, *VARIANTS[:2], VARIANTS[3]])):
|
||||
with self.subTest(pretty=pretty):
|
||||
result, state, contents, requests, files = self.run_loader(names=names, pretty=pretty)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(state["FILE"], "tinycore.img")
|
||||
self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img"))
|
||||
self.assertEqual([path for path in requests if path.startswith("/asset/")],
|
||||
[f"/asset/{PLAIN}"])
|
||||
self.assertEqual(files, ["tinycore.img"])
|
||||
|
||||
def assert_failed_acquisition(self, outcome, expected="tinycore.img"):
|
||||
result, state, contents, requests, files = outcome
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(state["FILE"], "", result.stdout)
|
||||
self.assertEqual(state["LOADER_FILE"], "", result.stdout)
|
||||
self.assertEqual(contents, b"previous verified image")
|
||||
self.assertNotIn("OK:", result.stdout)
|
||||
self.assertNotIn("UNEXPECTED_RECURSION", result.stdout)
|
||||
self.assertNotIn("FORBIDDEN", result.stderr)
|
||||
self.assertEqual(files, [expected])
|
||||
return requests
|
||||
|
||||
def test_zip_loaders_parse_minified_metadata(self):
|
||||
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
with self.subTest(loader=loader):
|
||||
result, state, contents, requests, files = self.run_loader(
|
||||
loader, names=["README.txt", name], pretty=False,
|
||||
payload=zip_bytes(member))
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(state["FILE"], member)
|
||||
self.assertTrue(state["LOADER_FILE"].endswith("/" + member))
|
||||
self.assertEqual(files, [member])
|
||||
self.assertEqual(requests[-1], "/asset/" + name)
|
||||
|
||||
def test_metadata_requires_one_typed_unambiguous_asset(self):
|
||||
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
|
||||
("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
cases = {
|
||||
"none": {"assets": []},
|
||||
"duplicate": {"assets": [asset(name), asset(name)]},
|
||||
"two_versions": {"assets": [asset(name), asset(name.replace("v1.4.5.6", "v2.0.0") if loader == "tinycore" else "another.img.zip")]},
|
||||
"null_name": {"assets": [{"name": None, "browser_download_url": asset(name)["browser_download_url"]}]},
|
||||
"numeric_name": {"assets": [{"name": 7, "browser_download_url": asset(name)["browser_download_url"]}]},
|
||||
"missing_name": {"assets": [{"browser_download_url": asset(name)["browser_download_url"]}]},
|
||||
"null_url": {"assets": [{"name": name, "browser_download_url": None}]},
|
||||
"numeric_url": {"assets": [{"name": name, "browser_download_url": 7}]},
|
||||
"empty_url": {"assets": [{"name": name, "browser_download_url": ""}]},
|
||||
"http_url": {"assets": [{"name": name, "browser_download_url": "http://bad/" + name}]},
|
||||
"mismatched_url": {"assets": [{"name": name, "browser_download_url": "https://bad/other.img.gz"}]},
|
||||
"whitespace_url": {"assets": [{"name": name, "browser_download_url": "https://bad/ has space/" + name}]},
|
||||
"control_url": {"assets": [{"name": name, "browser_download_url": "https://fixtures.invalid/\u0000/" + name}]},
|
||||
"wrong_assets_type": {"assets": {}},
|
||||
"array_root": [asset(name)],
|
||||
"error_object": {"message": "API rate limit exceeded"},
|
||||
}
|
||||
for case, document in cases.items():
|
||||
with self.subTest(loader=loader, case=case):
|
||||
requests = self.assert_failed_acquisition(self.run_loader(
|
||||
loader, metadata=json.dumps(document).encode()), member)
|
||||
self.assertFalse(any(path.startswith("/asset/") for path in requests))
|
||||
|
||||
def test_metadata_transport_or_json_failure_is_not_success(self):
|
||||
for label, metadata, status in (
|
||||
("http_failure_with_valid_body", json.dumps({"assets": [asset(PLAIN)]}).encode(), 503),
|
||||
("html_error_body", b"<html>unavailable</html>", 200),
|
||||
("truncated_json", b'{"assets":[', 200),
|
||||
("multiple_documents", json.dumps({"assets": [asset(PLAIN)]}).encode() * 2, 200),
|
||||
):
|
||||
with self.subTest(case=label):
|
||||
requests = self.assert_failed_acquisition(self.run_loader(
|
||||
metadata=metadata, metadata_status=status))
|
||||
self.assertFalse(any(path.startswith("/asset/") for path in requests))
|
||||
|
||||
def test_failed_empty_or_partial_download_preserves_previous_image(self):
|
||||
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
|
||||
("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
payload = gzip.compress(RAW) if loader == "tinycore" else zip_bytes(member)
|
||||
for label, body, options in (
|
||||
("http_error_with_archive_body", payload, {"asset_status": 503}),
|
||||
("empty_download", b"", {}),
|
||||
("partial_download", payload, {"partial": True}),
|
||||
):
|
||||
with self.subTest(loader=loader, case=label):
|
||||
requests = self.assert_failed_acquisition(self.run_loader(
|
||||
loader, names=[name], payload=body, **options), member)
|
||||
self.assertEqual(requests[-1], "/asset/" + name)
|
||||
|
||||
def test_invalid_or_empty_compressed_image_cannot_replace_cached_image(self):
|
||||
valid_gzip = gzip.compress(RAW)
|
||||
crc_gzip = valid_gzip[:-8] + bytes([valid_gzip[-8] ^ 1]) + valid_gzip[-7:]
|
||||
for label, payload in (
|
||||
("not_gzip", b"<html>not an image</html>"),
|
||||
("truncated_gzip", valid_gzip[:-6]),
|
||||
("gzip_crc_failure", crc_gzip),
|
||||
("empty_raw_image", gzip.compress(b"")),
|
||||
):
|
||||
with self.subTest(case=label):
|
||||
self.assert_failed_acquisition(self.run_loader(payload=payload))
|
||||
|
||||
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
stored = io.BytesIO()
|
||||
with zipfile.ZipFile(stored, "w", zipfile.ZIP_STORED) as archive:
|
||||
archive.writestr(member, RAW)
|
||||
crc_zip = stored.getvalue().replace(RAW, b"?" + RAW[1:], 1)
|
||||
valid_zip = zip_bytes(member)
|
||||
for label, payload in (
|
||||
("not_zip", b"<html>not an archive</html>"),
|
||||
("truncated_zip", valid_zip[:-30]),
|
||||
("zip_crc_failure", crc_zip),
|
||||
("empty_raw_image", zip_bytes(member, b"")),
|
||||
("missing_member", zip_bytes("other.img")),
|
||||
("nested_member", zip_bytes("subdirectory/" + member)),
|
||||
):
|
||||
with self.subTest(loader=loader, case=label):
|
||||
self.assert_failed_acquisition(self.run_loader(
|
||||
loader, names=[name], payload=payload), member)
|
||||
|
||||
def test_alpine_menu_and_selection_name_preserve_internal_contract(self):
|
||||
result, state, contents, requests, files = self.run_loader(
|
||||
functions=("select_loader",), invoke="select_loader",
|
||||
before=r'''whiptail() { printf 'MENU:%s\n' "$*"; printf '3' >&2; }''')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Alpine RedPill Loader (PeterSuh-Q3 M-shell)", result.stderr)
|
||||
self.assertIn("Selected Loader: Alpine RedPill M-shell", result.stdout)
|
||||
self.assertNotIn("TinyCore RedPill", result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(state["FILE"], "tinycore.img")
|
||||
self.assertIn("/metadata/PeterSuh-Q3/tinycore-redpill/releases/latest", requests)
|
||||
|
||||
def test_zip_expected_member_must_be_unique(self):
|
||||
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
duplicate = io.BytesIO()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
with zipfile.ZipFile(duplicate, "w") as archive:
|
||||
archive.writestr(member, RAW)
|
||||
archive.writestr(member, b"competing image")
|
||||
with self.subTest(loader=loader):
|
||||
self.assert_failed_acquisition(self.run_loader(
|
||||
loader, names=[name], payload=duplicate.getvalue()), member)
|
||||
|
||||
def run_wizard(self, loader, mode, *, main=False, **options):
|
||||
functions = (
|
||||
"select_loader", "select_disk_type", "_build_storage_plan_summary",
|
||||
"default_settings", "advanced_settings", "start_script", "create_vm",
|
||||
)
|
||||
before = f"WIZARD_MODE={mode}\nLOADER_CHOICE={ {'arc': 1, 'redpill': 2, 'tinycore': 3}[loader] }\n"
|
||||
# Only UI/storage-entry/capture/host boundaries are replaced. The actual
|
||||
# loader -> disk plan -> settings -> start_script chain runs in Bash.
|
||||
before += r'''
|
||||
NEXTID=123
|
||||
NAME='Synology VM'
|
||||
GEN_MAC='02:00:00:00:00:01'
|
||||
header_info() { :; }
|
||||
start_vm_wizard_capture() { :; }
|
||||
stop_vm_wizard_capture() { :; }
|
||||
prompt_optional_gpu_passthrough() { printf 'GPU_BOUNDARY\n'; }
|
||||
select_virtual_disk() { VIRTUAL_DISKS+=('local:32'); }
|
||||
pct() { return 1; }
|
||||
qm() {
|
||||
[[ "$1" == status ]] && return 1
|
||||
printf 'QM_BOUNDARY:%s\n' "$*"
|
||||
return 97
|
||||
}
|
||||
whiptail() {
|
||||
case "$*" in
|
||||
*'--title SETTINGS --yesno'*) [[ "$WIZARD_MODE" == default ]]; return ;;
|
||||
*'VIRTUAL MACHINE ID'*) printf '123' >&2 ;;
|
||||
*'MACHINE TYPE'*) printf 'q35' >&2 ;;
|
||||
*'BIOS TYPE'*) printf 'ovmf' >&2 ;;
|
||||
*'HOSTNAME'*) printf 'Synology-DSM' >&2 ;;
|
||||
*'CPU MODEL'*) printf '1' >&2 ;;
|
||||
*'CORE COUNT'*) printf '2' >&2 ;;
|
||||
*'--title RAM '*) printf '4096' >&2 ;;
|
||||
*'--title BRIDGE '*) printf 'vmbr0' >&2 ;;
|
||||
*'MAC ADDRESS'*) printf '02:00:00:00:00:01' >&2 ;;
|
||||
*'STORAGE PLAN'*)
|
||||
if [[ ${#VIRTUAL_DISKS[@]} -eq 0 ]]; then printf 'a' >&2; else printf 'd' >&2; fi ;;
|
||||
*'SELECT LOADER'*) printf '%s' "$LOADER_CHOICE" >&2 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
'''
|
||||
invoke = "start_script"
|
||||
if main:
|
||||
match = re.search(r"^# Main execution\n# =+\n(.*)", SOURCE.read_text(),
|
||||
re.MULTILINE | re.DOTALL)
|
||||
if match is None:
|
||||
self.fail("Native main gate not found")
|
||||
invoke = match.group(1)
|
||||
return self.run_loader(loader, functions=functions, before=before,
|
||||
invoke=invoke, **options)
|
||||
|
||||
def test_native_settings_chain_propagates_loader_failure(self):
|
||||
for mode in ("default", "advanced"):
|
||||
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
|
||||
("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
for failure in ("metadata", "archive", "download"):
|
||||
with self.subTest(mode=mode, loader=loader, failure=failure):
|
||||
result, state, contents, requests, files = self.run_wizard(
|
||||
loader, mode, names=[name],
|
||||
metadata=b'{"assets":[]}' if failure == "metadata" else None,
|
||||
payload=b"invalid compressed image",
|
||||
asset_status=503 if failure == "download" else 200)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(state["FILE"], "")
|
||||
self.assertEqual(state["LOADER_FILE"], "")
|
||||
self.assertEqual(contents, b"previous verified image")
|
||||
self.assertNotIn("Downloaded", result.stdout)
|
||||
self.assertNotIn("QM_BOUNDARY", result.stdout)
|
||||
self.assertNotIn("FORBIDDEN", result.stderr)
|
||||
self.assertEqual(files, [member])
|
||||
|
||||
def test_native_main_gate_never_reaches_qm_create_after_loader_failure(self):
|
||||
for mode in ("default", "advanced"):
|
||||
for loader in ("tinycore", "arc", "redpill"):
|
||||
with self.subTest(mode=mode, loader=loader):
|
||||
result, state, contents, requests, files = self.run_wizard(
|
||||
loader, mode, main=True, metadata=b'{"assets":[]}')
|
||||
# The existing cancellation convention exits zero; failure
|
||||
# must still stop the native main before GPU or VM creation.
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("VM creation cancelled before disk planning.", result.stdout)
|
||||
self.assertNotIn("QM_BOUNDARY", result.stdout)
|
||||
self.assertNotIn("GPU_BOUNDARY", result.stdout)
|
||||
self.assertNotIn("FORBIDDEN", result.stderr)
|
||||
self.assertEqual(contents, b"previous verified image")
|
||||
|
||||
def test_native_main_positive_control_reaches_only_stubbed_qm_boundary(self):
|
||||
for mode in ("default", "advanced"):
|
||||
with self.subTest(mode=mode):
|
||||
result, state, contents, requests, files = self.run_wizard("tinycore", mode, main=True)
|
||||
self.assertEqual(result.stdout.count("QM_BOUNDARY:create "), 1, result.stdout + result.stderr)
|
||||
self.assertIn("GPU_BOUNDARY", result.stdout)
|
||||
self.assertEqual(contents, RAW)
|
||||
|
||||
def test_custom_loader_selection_keeps_existing_path_without_network(self):
|
||||
result, state, contents, requests, files = self.run_loader(
|
||||
functions=("select_loader", "select_custom_image"), invoke="select_loader",
|
||||
before=r'''
|
||||
whiptail() {
|
||||
if [[ "$*" == *'SELECT CUSTOM IMAGE'* ]]; then
|
||||
printf '%s/tinycore.img' "$IMAGES_DIR" >&2
|
||||
else
|
||||
printf '4' >&2
|
||||
fi
|
||||
}
|
||||
''')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(state["LOADER_TYPE"], "custom")
|
||||
self.assertEqual(state["FILE"], "tinycore.img")
|
||||
self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img"))
|
||||
self.assertEqual(contents, b"previous verified image")
|
||||
self.assertEqual(requests, [])
|
||||
self.assertEqual(files, ["tinycore.img"])
|
||||
|
||||
def test_zip_extracts_only_expected_image_not_sibling_files(self):
|
||||
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
|
||||
("redpill", "rr-26.10.img.zip", "rr.img")):
|
||||
with self.subTest(loader=loader):
|
||||
result, state, contents, requests, files = self.run_loader(
|
||||
loader, names=[name], payload=zip_bytes(member, extras=(
|
||||
("sha256sum", b"fixture checksum text"),
|
||||
("other.img", b"must not be published"))))
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(files, [member])
|
||||
|
||||
def test_alpine_selection_is_not_pinned_to_current_release(self):
|
||||
name = "alpine-redpill.v2.0.1.m-shell.img.gz"
|
||||
result, state, contents, requests, files = self.run_loader(names=[name, *VARIANTS])
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(contents, RAW)
|
||||
self.assertEqual(requests[-1], "/asset/" + name)
|
||||
|
||||
def test_variants_or_legacy_family_never_substitute_for_plain_alpine(self):
|
||||
self.assert_failed_acquisition(self.run_loader(
|
||||
names=[*VARIANTS, "tinycore-redpill.v1.0.0.m-shell.img.gz"]))
|
||||
|
||||
def test_staging_or_promotion_failure_keeps_previous_image(self):
|
||||
for boundary in ("mktemp", "mv"):
|
||||
with self.subTest(boundary=boundary):
|
||||
self.assert_failed_acquisition(self.run_loader(
|
||||
before=f"{boundary}() {{ return 1; }}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Offline regressions for copyable commands and runtime format fields."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
ROOT = SCRIPTS.parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py')
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC and SPEC.loader
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class TerminalCatalogsTests(unittest.TestCase):
|
||||
def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self):
|
||||
fixtures = {
|
||||
'menu': 'translate "Main launcher"',
|
||||
'install_proxmenux.sh': 'translate "Stable installer"',
|
||||
'install_proxmenux_beta.sh': 'translate "Beta installer"',
|
||||
'scripts/check.sh': 'translate "Host shell script"',
|
||||
'scripts/check.py': 'translate("Host Python script")',
|
||||
'oci/src/check.py': 'N_("OCI Python script")',
|
||||
'oci/remote/check.py': 'translate("Remote Python script")',
|
||||
'oci/remote/check.sh': 'translate "Remote shell script"',
|
||||
'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}),
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for name, content in fixtures.items():
|
||||
file = root / name
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(content, encoding='utf-8')
|
||||
self.assertEqual(set(MODULE.active_sources(root)), {
|
||||
'Main launcher', 'Stable installer', 'Beta installer',
|
||||
'Host shell script', 'Host Python script', 'OCI Python script',
|
||||
'Remote Python script', 'Remote shell script', 'Application tagline',
|
||||
})
|
||||
|
||||
def test_runtime_format_field_cannot_be_translated(self):
|
||||
source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}'
|
||||
with self.assertRaisesRegex(ValueError, 'placeholder'):
|
||||
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}')
|
||||
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}')
|
||||
|
||||
def test_copyable_command_and_environment_variable_are_literal(self):
|
||||
examples = [
|
||||
('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'),
|
||||
("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."),
|
||||
('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'),
|
||||
]
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
|
||||
MODULE.validate_message(source, target)
|
||||
MODULE.validate_message('Example: pct config 110 | grep unprivileged',
|
||||
'Ejemplo: pct config 110 | grep unprivileged')
|
||||
|
||||
def test_dns_capitalization_does_not_relax_file_path_case(self):
|
||||
MODULE.validate_message('Cannot reach download.proxmox.com.',
|
||||
'Download.proxmox.com ist nicht erreichbar.')
|
||||
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
|
||||
MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg')
|
||||
|
||||
def test_shipped_active_catalogs_keep_literal_contracts(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for locale in MODULE.DEFAULT_LANGUAGES:
|
||||
self.assertIn(f'[{locale}] active=', result.stdout)
|
||||
# New strings are reviewed in Slovak. Other locales keep the runtime
|
||||
# English fallback until the translation bot supplies their values;
|
||||
# still validate every translation already present in those locales.
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--languages', 'sk', '--require-complete'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn('missing=0', result.stdout)
|
||||
|
||||
def test_optional_completeness_still_rejects_invalid_present_translations(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / 'scripts').mkdir()
|
||||
(root / 'lang').mkdir()
|
||||
(root / 'scripts/check.sh').write_text('translate "Check /etc/pve/storage.cfg"')
|
||||
(root / 'lang/sk.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Skontrolujte /etc/pve/storage.cfg'}))
|
||||
(root / 'lang/es.json').write_text('{}')
|
||||
|
||||
def check(languages, *flags):
|
||||
return subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(root), '--languages', languages, *flags],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
|
||||
self.assertEqual(check('es,sk').returncode, 0)
|
||||
self.assertEqual(check('sk', '--require-complete').returncode, 0)
|
||||
self.assertNotEqual(check('es', '--require-complete').returncode, 0)
|
||||
(root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'}))
|
||||
self.assertNotEqual(check('es,sk').returncode, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Every web terminal of the Monitor can send Ctrl+D from its Ctrl menu, so
|
||||
a pasted text can be finished from a phone or a tablet."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
TERMINALS = {
|
||||
'terminal-panel': 'sendSequence',
|
||||
'script-terminal-modal': 'sendCommand',
|
||||
'lxc-terminal-modal': 'sendKey',
|
||||
}
|
||||
|
||||
|
||||
class TerminalCtrlKeysTests(unittest.TestCase):
|
||||
def test_each_terminal_sends_ctrl_d(self):
|
||||
for name, send in TERMINALS.items():
|
||||
source = (ROOT / f'AppImage/components/{name}.tsx').read_text(encoding='utf-8')
|
||||
with self.subTest(terminal=name):
|
||||
self.assertIn(f'onSelect={{() => {send}("\\x04")}}', source)
|
||||
self.assertIn('t("scriptTerminal.endOfInput")', source)
|
||||
|
||||
def test_the_label_exists_in_english_and_spanish(self):
|
||||
for language in ('en', 'es'):
|
||||
catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8'))
|
||||
with self.subTest(language=language):
|
||||
self.assertTrue(catalog['scriptTerminal'].get('endOfInput'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""A script writes a line break as \\n inside the text it translates; the
|
||||
language files store that text with a real line break."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def function(text, name):
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}\n', start) + 3]
|
||||
|
||||
|
||||
class TranslateLineBreaks(TestCase):
|
||||
def translate(self, text, catalog):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
(Path(folder) / 'xx.json').write_text(json.dumps(catalog))
|
||||
script = (f'LANGUAGE=xx LANG_DIR={folder}\n' + function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
|
||||
+ 'translate "$1"\n')
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script, 'bash', text],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual((result.returncode, result.stderr), (0, ''))
|
||||
return result.stdout.rstrip('\n')
|
||||
|
||||
def test_a_text_stored_with_a_real_line_break_is_found(self):
|
||||
catalog = {'Restore this backup?\nThe configuration is overwritten.': '¿Restaurar?\nSe sobrescribe.'}
|
||||
self.assertEqual(self.translate('Restore this backup?\\nThe configuration is overwritten.', catalog),
|
||||
'¿Restaurar?\nSe sobrescribe.')
|
||||
|
||||
def test_the_exact_text_is_preferred_and_a_missing_one_comes_back_unchanged(self):
|
||||
catalog = {'one\\ntwo': 'LITERAL', 'one\ntwo': 'REAL', 'plain': 'simple'}
|
||||
self.assertEqual(self.translate('one\\ntwo', catalog), 'LITERAL')
|
||||
self.assertEqual(self.translate('plain', catalog), 'simple')
|
||||
self.assertEqual(self.translate('not\\nthere', catalog), 'not\\nthere')
|
||||
|
||||
def test_the_shipped_texts_with_a_line_break_reach_the_spanish_file(self):
|
||||
spanish = json.loads((ROOT / 'lang/es.json').read_text())
|
||||
text = 'Are you sure you want to restore this backup?\\nCurrent configuration will be overwritten.'
|
||||
self.assertEqual(self.translate(text, spanish), spanish[text.replace('\\n', '\n')])
|
||||
@@ -0,0 +1,25 @@
|
||||
"""A label made only of product and API names is right when it comes back
|
||||
unchanged, so the translation bot keeps it instead of retrying and dropping it."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import build_translation_cache as cache # noqa: E402
|
||||
|
||||
|
||||
class ProtectedTermsTests(unittest.TestCase):
|
||||
def test_acceleration_profile_labels_are_kept_unchanged(self):
|
||||
for label in ("Intel/AMD (Vulkan)", "AMD (ROCm)", "Intel (SYCL)", "NVIDIA (CUDA)",
|
||||
"Intel/AMD (VA-API)", "Intel (OpenVINO)"):
|
||||
with self.subTest(label=label):
|
||||
self.assertTrue(cache.is_fully_protected(label))
|
||||
self.assertFalse(cache.looks_untranslated(label, label))
|
||||
|
||||
def test_a_sentence_coming_back_unchanged_is_still_a_failure(self):
|
||||
sentence = "The GPU is used through Vulkan when it is available"
|
||||
self.assertTrue(cache.looks_untranslated(sentence, sentence))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Offline, bounded real Bash consumers: no administrative entrypoints are sourced."""
|
||||
import importlib.util
|
||||
import json
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS = ROOT / 'scripts'
|
||||
NETWORK = {
|
||||
'share/iscsi_host.sh': 1,
|
||||
'share/nfs_client.sh': 1,
|
||||
'share/nfs_host.sh': 2,
|
||||
'share/samba_client.sh': 1,
|
||||
'share/samba_host.sh': 2,
|
||||
}
|
||||
MOUNT = 'Mounting existing filesystem ({filesystem})...'
|
||||
RAID = 'If you are sure you want to use it, remove the RAID metadata or format it manually using external tools.'
|
||||
|
||||
|
||||
def function(text, name):
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}', start) + 2] + '\n'
|
||||
|
||||
|
||||
def network_blocks(path):
|
||||
lines = path.read_text().splitlines()
|
||||
blocks = []
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r'^\s*if (?:pct exec .* -- )?nc -z -w 2 ', line):
|
||||
depth = len(line) - len(line.lstrip())
|
||||
end = next(j for j in range(i + 1, len(lines))
|
||||
if lines[j].strip() == 'fi' and len(lines[j]) - len(lines[j].lstrip()) == depth)
|
||||
block = '\n'.join(lines[i:end + 1])
|
||||
assert '$(translate "Open")' in block and '$(translate "Closed")' in block or '$(translate "Probe failed")' in block
|
||||
blocks.append(block)
|
||||
return blocks
|
||||
|
||||
|
||||
def raid_dialog():
|
||||
"""The dialog that reports a RAID member, wherever it sits in the script."""
|
||||
lines = (SCRIPTS / 'storage/disk-passthrough_ct.sh').read_text().splitlines()
|
||||
message = next(i for i, line in enumerate(lines) if RAID in line)
|
||||
assert lines[message - 2].lstrip().startswith('dialog ')
|
||||
return '\n'.join(lines[message - 2:message + 1])
|
||||
|
||||
|
||||
def run(code, translations=None, statuses='1', variables='', dialog=False):
|
||||
translations = translations or {}
|
||||
with tempfile.TemporaryDirectory(prefix='tui-wording-') as directory:
|
||||
root = Path(directory)
|
||||
(root / 'xx.json').write_text(json.dumps(translations))
|
||||
body = f'''LANGUAGE=xx LANG_DIR={root!s}
|
||||
{function((SCRIPTS / 'utils.sh').read_text(), 'translate')}
|
||||
GN='\\033[32m' RD='\\033[31m' CL='\\033[0m'
|
||||
{variables}
|
||||
statuses=({statuses})
|
||||
nc() {{ local code=${{statuses[0]}}; statuses=("${{statuses[@]:1}}"); return "$code"; }}
|
||||
pct() {{ [[ "$1" == exec && "$3" == -- && "$4" == nc ]] || return 98; shift 3; nc "$@"; }}
|
||||
msg_info() {{ printf 'INFO:%s\\n' "$1"; }}
|
||||
msg_error() {{ printf 'ERROR:%s\\n' "$1"; }}
|
||||
msg_ok() {{ printf 'OK:%s\\n' "$1"; }}
|
||||
msg_title() {{ :; }}
|
||||
show_proxmenux_logo() {{ :; }}
|
||||
pmx_journal_context() {{ :; }}
|
||||
blkid() {{ [[ "$2" == TYPE ]] && printf 'ext4\\n' || printf 'UUID123\\n'; }}
|
||||
mkdir() {{ :; }}
|
||||
mount() {{ return 1; }}
|
||||
dialog() {{ printf 'DIALOG:%s\\n' "$*"; }}
|
||||
{code}
|
||||
'''
|
||||
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', body],
|
||||
env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C.UTF-8'},
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if result.returncode or result.stderr:
|
||||
raise AssertionError((result.returncode, result.stderr, result.stdout))
|
||||
return result.stdout
|
||||
|
||||
|
||||
class TuiNetworkStorageWording(unittest.TestCase):
|
||||
def test_failed_tcp_probe_is_not_declared_closed_at_every_caller(self):
|
||||
for path, count in NETWORK.items():
|
||||
blocks = network_blocks(SCRIPTS / path)
|
||||
self.assertEqual(len(blocks), count, path)
|
||||
for index, block in enumerate(blocks):
|
||||
cases = [('0 1', 'Open'), ('1 1', 'Probe failed'), ('124 124', 'Probe failed')]
|
||||
if 'samba_' in path:
|
||||
cases.append(('1 0', 'Open'))
|
||||
for codes, expected in cases:
|
||||
with self.subTest(path=path, index=index, codes=codes):
|
||||
output = run(block, statuses=codes, translations={'Probe failed': 'SONDAGGIO FALLITO'})
|
||||
self.assertIn('SONDAGGIO FALLITO' if expected == 'Probe failed' else 'Open', output)
|
||||
self.assertNotIn('Closed', output)
|
||||
if expected == 'Open':
|
||||
self.assertNotIn('SONDAGGIO FALLITO', output)
|
||||
else:
|
||||
fallback = run(block, statuses=codes)
|
||||
self.assertIn('Probe failed', fallback)
|
||||
self.assertNotIn('Closed', fallback)
|
||||
|
||||
def test_mount_progress_is_whole_and_uses_real_lookup_with_fallback(self):
|
||||
source = (SCRIPTS / 'share/disk_host.sh').read_text()
|
||||
start = source.index('mount_existing_disk() {')
|
||||
portion = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\n'
|
||||
for translation, expected in [({}, MOUNT.replace('{filesystem}', 'ext4')),
|
||||
({MOUNT: 'SYNTHETIC {filesystem} in corso'}, 'SYNTHETIC ext4 in corso'),
|
||||
({MOUNT: 'SYNTHETIC malformed'}, MOUNT.replace('{filesystem}', 'ext4'))]:
|
||||
with self.subTest(translation=translation):
|
||||
output = run(portion + '\nmount_existing_disk /dev/fixture /mnt/fixture', translations=translation)
|
||||
self.assertIn('INFO:' + expected, output)
|
||||
self.assertNotIn('{filesystem}', output)
|
||||
|
||||
def test_raid_warning_is_complete_message_with_disk_identifier_and_fallback(self):
|
||||
invocation = raid_dialog() # the dialog invocation, not the raid detection command
|
||||
for translation, expected in [({}, RAID), ({RAID: 'SYNTHETIC RAID metadata remains'}, 'SYNTHETIC RAID metadata remains')]:
|
||||
with self.subTest(translation=translation):
|
||||
output = run(invocation, translations=translation,
|
||||
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture 2TiB' UI_RESULT_H=16 UI_RESULT_W=80")
|
||||
self.assertIn(expected, output)
|
||||
self.assertIn('/dev/fixture 2TiB', output)
|
||||
self.assertNotIn('remove the RAID metadata or format it manually using external tools. RAID metadata', output)
|
||||
|
||||
def test_shipped_catalogs_use_english_fallback_for_new_keys(self):
|
||||
catalogs = sorted((ROOT / 'lang').glob('*.json'))
|
||||
self.assertTrue(catalogs)
|
||||
block = network_blocks(SCRIPTS / 'share/iscsi_host.sh')[0]
|
||||
source = (SCRIPTS / 'share/disk_host.sh').read_text()
|
||||
start = source.index('mount_existing_disk() {')
|
||||
mount = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\nmount_existing_disk /dev/fixture /mnt/fixture'
|
||||
raid = raid_dialog()
|
||||
for path in catalogs:
|
||||
with self.subTest(locale=path.name):
|
||||
catalog = json.loads(path.read_text())
|
||||
self.assertIn(catalog.get('Probe failed') or 'Probe failed', run(block, statuses='1', translations=catalog))
|
||||
self.assertIn((catalog.get(MOUNT) or MOUNT).replace('{filesystem}', 'ext4'), run(mount, translations=catalog))
|
||||
self.assertIn(catalog.get(RAID) or RAID, run(raid, translations=catalog,
|
||||
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture' UI_RESULT_H=16 UI_RESULT_W=80"))
|
||||
|
||||
def test_extractor_discovers_whole_keys(self):
|
||||
spec = importlib.util.spec_from_file_location('cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
keys = module.extract_translate_texts(SCRIPTS)
|
||||
for key in (MOUNT, RAID, 'Probe failed'):
|
||||
self.assertTrue(key in keys, key)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Run the inert Node TUI consumer fixture in the existing offline i18n lane."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class TuiUpgradeGpuWording(unittest.TestCase):
|
||||
def test_source_extracted_node_consumer(self):
|
||||
fixture = ROOT / 'tests/test_tui_upgrade_gpu_wording.cjs'
|
||||
self.assertTrue(fixture.is_file(), 'Node fixture must be packaged with the candidate')
|
||||
node = os.environ.get('NODE_BINARY') or 'node'
|
||||
result = subprocess.run([node, str(fixture)], cwd=ROOT, capture_output=True,
|
||||
text=True, timeout=60, env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'})
|
||||
self.assertEqual(result.returncode, 0, result.stdout + '\n' + result.stderr)
|
||||
self.assertIn('PASS: extracted upgrade/GPU consumers', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""The VM/CT backup jobs feature is protected, shipped and translated."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
ROUTES = ROOT / 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
SCRIPT = ROOT / 'scripts/backup_restore/vm_backup_jobs.sh'
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
|
||||
|
||||
class VmBackupJobsWiring(TestCase):
|
||||
def test_every_route_is_protected_and_every_change_needs_an_administrator(self):
|
||||
source = ROUTES.read_text()
|
||||
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
|
||||
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
|
||||
self.assertEqual(len(routes), 10)
|
||||
for path, method, guard, name in routes:
|
||||
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
|
||||
self.assertEqual(guard, expected, f'{method} {path} ({name})')
|
||||
|
||||
def test_proxmox_is_called_without_a_shell(self):
|
||||
source = ROUTES.read_text()
|
||||
self.assertNotIn('shell=True', source)
|
||||
self.assertNotIn('os.system', source)
|
||||
self.assertEqual(source.count('subprocess.run('), 1)
|
||||
self.assertIn('subprocess.run(["pvesh", *args]', source)
|
||||
|
||||
def test_the_monitor_ships_and_registers_it(self):
|
||||
self.assertIn('flask_vm_backup_routes.py', (ROOT / 'AppImage/scripts/build_appimage.sh').read_text())
|
||||
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
|
||||
self.assertIn('from flask_vm_backup_routes import vm_backup_bp', server)
|
||||
self.assertIn('app.register_blueprint(vm_backup_bp)', server)
|
||||
self.assertIn('<BackupTab ', (ROOT / 'AppImage/components/proxmox-dashboard.tsx').read_text())
|
||||
tab = (ROOT / 'AppImage/components/backup-tab.tsx').read_text()
|
||||
self.assertIn('<HostBackup />', tab)
|
||||
self.assertIn('<VmBackupJobs />', tab)
|
||||
# The backups of the host and those of the guests are separate views.
|
||||
self.assertNotIn('VmBackupJobs', (ROOT / 'AppImage/components/host-backup.tsx').read_text())
|
||||
|
||||
def test_every_text_of_the_card_exists_in_the_eight_languages(self):
|
||||
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
|
||||
used = set(re.findall(r'backup\.vmJobs\.([A-Za-z]+)', component))
|
||||
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
|
||||
self.assertEqual(used - set(english), set())
|
||||
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
|
||||
for locale in LOCALES:
|
||||
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
|
||||
self.assertEqual(set(texts), set(english), locale)
|
||||
self.assertEqual(set(texts['presets']), set(english['presets']), locale)
|
||||
switch = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['viewSwitch']
|
||||
self.assertEqual(set(switch), {'ariaLabel', 'host', 'guests'}, locale)
|
||||
for key, value in english.items():
|
||||
if isinstance(value, str):
|
||||
self.assertTrue(texts[key].strip(), f'{locale} {key}')
|
||||
self.assertEqual(fields(texts[key]), fields(value), f'{locale} {key}')
|
||||
|
||||
def test_the_edit_dialog_follows_the_edit_mode_contrast_rule(self):
|
||||
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
|
||||
self.assertIn('bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background', component)
|
||||
|
||||
def test_the_menu_runs_a_job_as_a_proxmox_task_and_installs_nothing(self):
|
||||
source = SCRIPT.read_text()
|
||||
self.assertNotIn('apt-get', source)
|
||||
self.assertIn('pvesh create "/nodes/$node/vzdump"', source)
|
||||
self.assertEqual(re.findall(r'^\s*vzdump ', source, re.MULTILINE), [])
|
||||
menu = (ROOT / 'scripts/backup_restore/backup_host.sh').read_text()
|
||||
self.assertIn('bash "$LOCAL_SCRIPTS/backup_restore/vm_backup_jobs.sh"', menu)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""The VM wizard must not accept failed ISO downloads as installation media.
|
||||
|
||||
Run its function logic against a loopback HTTP fixture. Redirect the fixed
|
||||
native ISO directory only in the loaded test copy and stub Proxmox/UI services.
|
||||
No product script is sourced, no guest operation runs, and no public network
|
||||
or real ISO storage is accessed.
|
||||
"""
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CREATOR = ROOT / "scripts/vm/vm_creator.sh"
|
||||
PAYLOAD = b"fixture installation media\x00\x01\n"
|
||||
REAL_WGET = shutil.which("wget")
|
||||
|
||||
|
||||
class MediaHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/missing.iso":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/partial.iso":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "1024")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"partial")
|
||||
self.close_connection = True
|
||||
return
|
||||
body = b"" if self.path == "/empty.iso" else PAYLOAD
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Content-Disposition", 'attachment; filename="remote-name.iso"')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
class VmIsoDownloadTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if REAL_WGET is None:
|
||||
raise RuntimeError("These offline tests require wget, as the VM wizard does")
|
||||
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), MediaHandler)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.http = f"http://127.0.0.1:{cls.server.server_port}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join()
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="vm-iso-download-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.directory = Path(self.temp.name)
|
||||
self.iso = self.directory / "installation media.iso"
|
||||
self.qm_log = self.directory / "qm.log"
|
||||
self.wget_log = self.directory / "wget.log"
|
||||
self.bin = self.directory / "bin"
|
||||
self.bin.mkdir()
|
||||
# Route only the optional fixed VirtIO/SourceForge endpoints to our
|
||||
# fixture. Wget still performs the actual HTTP transfer and file I/O.
|
||||
assert REAL_WGET is not None
|
||||
wrapper = self.bin / "wget"
|
||||
wrapper.write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
'printf "%s\\n" "$*" >> "$WGET_LOG"\n'
|
||||
'args=("$@")\n'
|
||||
'for i in "${!args[@]}"; do\n'
|
||||
' case "${args[$i]}" in\n'
|
||||
' https://fedorapeople.org/*) args[$i]="$VIRTIO_FIXTURE_URL";;\n'
|
||||
' https://sourceforge.net/*) args[$i]="$HTTP_FIXTURE/success.iso";;\n'
|
||||
' esac\n'
|
||||
' if [[ "${args[$i]}" == http://* || "${args[$i]}" == https://* ]]; then\n'
|
||||
' [[ "${args[$i]}" == "$HTTP_FIXTURE"/* ]] || exit 98\n'
|
||||
' fi\n'
|
||||
'done\n'
|
||||
'for i in "${!args[@]}"; do\n'
|
||||
' if [[ "${args[$i]}" == -O ]]; then\n'
|
||||
' case "${args[$((i+1))]}" in "$FIXTURE_DIR"/*) ;; *) exit 97;; esac\n'
|
||||
' fi\n'
|
||||
'done\n'
|
||||
f"exec {shlex.quote(REAL_WGET)} --no-config --no-proxy --tries=1 --timeout=1 "
|
||||
'"${args[@]}"\n'
|
||||
)
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
def run_wizard(self, route="/success.iso", windows=False, overwrite=True, url=None,
|
||||
caller_iso_dir=None):
|
||||
source = CREATOR.read_text()
|
||||
functions = "\n\n".join(re.findall(r"^function \w+\(\) \{\n.*?^\}", source, re.M | re.S))
|
||||
self.assertIn("function create_vm()", functions)
|
||||
# Only relocate this external filesystem anchor. Keep all predicates,
|
||||
# transfers and promotion logic unchanged, without a production hook.
|
||||
self.assertIn("/var/lib/vz/template/iso", functions)
|
||||
functions = functions.replace("/var/lib/vz/template/iso", "${FIXTURE_DIR}")
|
||||
prelude = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_ok() { printf 'OK:%s\n' "$*"; }
|
||||
msg_error() { printf 'ERROR:%s\n' "$*"; }
|
||||
msg_warn() { printf 'WARN:%s\n' "$*"; }
|
||||
msg_info() { :; }
|
||||
msg_success() { :; }
|
||||
qm() { printf '%q ' "$@" >> "$QM_LOG"; printf '\n' >> "$QM_LOG"; }
|
||||
pvesm() { printf 'Name Type Status\nfixture dir active\n'; }
|
||||
select_storage_target() { printf fixture; }
|
||||
# No next menu, start, or guest-facing process is launched.
|
||||
bash() { :; }
|
||||
whiptail() {
|
||||
if [[ " $* " == *' --yesno '* ]]; then
|
||||
return "$OVERWRITE_STATUS"
|
||||
fi
|
||||
if [[ ! -f "$DIALOG_ONCE" ]]; then
|
||||
: > "$DIALOG_ONCE"
|
||||
printf '1' >&2
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
IMPORT_DISKS=(); PASSTHROUGH_DISKS=(); VIRTUAL_DISKS=(); CONTROLLER_NVME_PCIS=()
|
||||
VMID=99999; HN=fixture; CORE_COUNT=1; RAM_SIZE=512; BRG=fixture
|
||||
MAC='02:00:00:00:00:01'; VLAN=''; MTU=''; BIOS_TYPE=''; CPU_TYPE=''
|
||||
MACHINE=''; SERIAL_PORT=''; START_VM=no; WIZARD_ADD_GPU=no
|
||||
TAB=''; GN=''; CL=''
|
||||
create_vm
|
||||
'''
|
||||
env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"],
|
||||
ISO_PATH=str(self.iso), ISO_URL=url if url is not None else self.http + route,
|
||||
ISO_VOLID="local:iso/installation media.iso",
|
||||
OS_TYPE="2" if windows else "3",
|
||||
QM_LOG=str(self.qm_log), WGET_LOG=str(self.wget_log),
|
||||
DIALOG_ONCE=str(self.directory / "dialog-once"), FIXTURE_DIR=str(self.directory),
|
||||
OVERWRITE_STATUS="0" if overwrite else "1", HTTP_FIXTURE=self.http,
|
||||
VIRTIO_FIXTURE_URL=self.http + route)
|
||||
env.pop("ISO_DIR", None)
|
||||
if caller_iso_dir is not None:
|
||||
env["ISO_DIR"] = str(caller_iso_dir)
|
||||
return subprocess.run(["bash", "--noprofile", "--norc", "-c", functions + prelude],
|
||||
env=env, stdin=subprocess.DEVNULL, capture_output=True,
|
||||
text=True, timeout=15)
|
||||
|
||||
def qm_calls(self):
|
||||
return self.qm_log.read_text() if self.qm_log.exists() else ""
|
||||
|
||||
def test_http_failure_stops_before_creating_a_guest(self):
|
||||
result = self.run_wizard("/missing.iso")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.qm_calls(), "")
|
||||
self.assertNotIn("OK:ISO image downloaded", result.stdout)
|
||||
self.assertIn("ERROR:Failed to download ISO image", result.stdout)
|
||||
|
||||
def test_partial_transfer_is_not_published_as_cached_media(self):
|
||||
result = self.run_wizard("/partial.iso")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.qm_calls(), "")
|
||||
self.assertFalse(self.iso.exists(), "A failed transfer must not become reusable media")
|
||||
|
||||
def test_an_empty_cached_download_is_retried(self):
|
||||
self.iso.write_bytes(b"")
|
||||
result = self.run_wizard()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.iso.read_bytes(), PAYLOAD)
|
||||
self.assertIn("create ", self.qm_calls())
|
||||
|
||||
def test_virtio_download_keeps_native_storage_despite_caller_environment(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
unrelated = self.directory / "unrelated directory"
|
||||
unrelated.mkdir()
|
||||
result = self.run_wizard(windows=True, caller_iso_dir=unrelated)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
self.assertTrue(driver.is_file(), result.stdout + result.stderr)
|
||||
self.assertEqual(driver.read_bytes(), PAYLOAD)
|
||||
self.assertIn("-ide3 ", self.qm_calls())
|
||||
self.assertEqual(list(unrelated.iterdir()), [])
|
||||
|
||||
def test_failed_virtio_fetch_is_not_selected_for_attachment(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
result = self.run_wizard("/missing.iso", windows=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn("-ide3 ", self.qm_calls())
|
||||
self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout)
|
||||
self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout)
|
||||
self.assertFalse((self.directory / "virtio-win.iso").exists())
|
||||
|
||||
|
||||
def test_successful_virtio_replacement_preserves_existing_permissions(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
driver.write_bytes(b"previous driver")
|
||||
driver.chmod(0o640)
|
||||
result = self.run_wizard(windows=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(driver.read_bytes(), PAYLOAD)
|
||||
self.assertEqual(driver.stat().st_mode & 0o777, 0o640)
|
||||
|
||||
def test_empty_http_success_is_rejected(self):
|
||||
result = self.run_wizard("/empty.iso")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.qm_calls(), "")
|
||||
self.assertFalse(self.iso.exists())
|
||||
self.assertEqual({p.name for p in self.directory.iterdir()}, {"bin", "wget.log"})
|
||||
|
||||
def test_existing_nonempty_media_is_reused_without_fetching(self):
|
||||
self.iso.write_bytes(b"existing installation media")
|
||||
result = self.run_wizard("/missing.iso")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.iso.read_bytes(), b"existing installation media")
|
||||
self.assertFalse(self.wget_log.exists())
|
||||
self.assertIn("-ide2 ", self.qm_calls())
|
||||
|
||||
def test_sourceforge_download_keeps_the_selected_destination_name(self):
|
||||
result = self.run_wizard(url="https://sourceforge.net/projects/fixture/iso/download")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.iso.read_bytes(), PAYLOAD)
|
||||
self.assertFalse((self.directory / "remote-name.iso").exists())
|
||||
self.assertEqual({p.name for p in self.directory.iterdir()},
|
||||
{"bin", "wget.log", "qm.log", self.iso.name})
|
||||
|
||||
def test_failed_virtio_replacement_preserves_previous_media(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
driver.write_bytes(b"previous driver")
|
||||
driver.chmod(0o640)
|
||||
result = self.run_wizard("/partial.iso", windows=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(driver.read_bytes(), b"previous driver")
|
||||
self.assertEqual(driver.stat().st_mode & 0o777, 0o640)
|
||||
self.assertNotIn("-ide3 ", self.qm_calls())
|
||||
self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout)
|
||||
|
||||
def test_declining_virtio_replacement_reuses_previous_media(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
driver.write_bytes(b"previous driver")
|
||||
result = self.run_wizard("/missing.iso", windows=True, overwrite=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(driver.read_bytes(), b"previous driver")
|
||||
self.assertFalse(self.wget_log.exists())
|
||||
self.assertIn("-ide3 ", self.qm_calls())
|
||||
|
||||
def test_empty_cached_virtio_media_is_retried_not_attached(self):
|
||||
self.iso.write_bytes(PAYLOAD)
|
||||
driver = self.directory / "virtio-win.iso"
|
||||
driver.write_bytes(b"")
|
||||
result = self.run_wizard("/missing.iso", windows=True, overwrite=False)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertTrue(self.wget_log.exists())
|
||||
self.assertNotIn("-ide3 ", self.qm_calls())
|
||||
self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout)
|
||||
|
||||
def test_a_directory_cannot_be_published_as_media(self):
|
||||
self.iso.mkdir()
|
||||
result = self.run_wizard()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.qm_calls(), "")
|
||||
self.assertTrue(self.iso.is_dir())
|
||||
self.assertEqual(list(self.iso.iterdir()), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Offline VM source regressions; execute bounded real functions, never selectors.
|
||||
|
||||
Fixtures are reduced official metadata excerpts captured 2026-10-16. Network
|
||||
commands are shell stubs: no ISO downloads, utils sourcing, or host changes.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LINUX = ROOT / "scripts/vm/select_linux_iso.sh"
|
||||
NAS = ROOT / "scripts/vm/select_nas_iso.sh"
|
||||
FIELDS = ("ISO_NAME", "ISO_TYPE", "ISO_URL", "ISO_FILE", "ISO_PATH", "HN")
|
||||
|
||||
|
||||
def extract_function(path, name):
|
||||
"""Only top-level function definitions with an unindented closing brace."""
|
||||
match = re.search(
|
||||
rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}(?=\n|$)",
|
||||
path.read_text(), re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if match is None:
|
||||
raise AssertionError(f"Missing bounded function: {name}")
|
||||
return match.group(0)
|
||||
|
||||
|
||||
class IsoSourceTests(unittest.TestCase):
|
||||
def run_selector(self, path, function, *, menu_name="", metadata="",
|
||||
metadata_url="", fetch_status=0, has_curl=True):
|
||||
with tempfile.TemporaryDirectory(prefix="vm-iso-sources-") as directory:
|
||||
temp = Path(directory)
|
||||
fixture = temp / "metadata.html"
|
||||
fixture.write_text(metadata)
|
||||
trace = temp / "fetches.txt"
|
||||
trace.touch()
|
||||
iso_dir = temp / "iso"
|
||||
iso_dir.mkdir()
|
||||
env = os.environ.copy()
|
||||
env.update(ISO_DIR=str(iso_dir), MENU_NAME=menu_name,
|
||||
METADATA_FILE=str(fixture), METADATA_URL=metadata_url,
|
||||
FETCH_STATUS=str(fetch_status), FETCH_TRACE=str(trace))
|
||||
helpers = "\n".join(
|
||||
extract_function(NAS, name)
|
||||
for name in ("_has_curl", "_latest_version_from_lines")
|
||||
)
|
||||
script = helpers + r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
curl() {
|
||||
local url="${@: -1}"
|
||||
printf '%s\n' "$url" >> "$FETCH_TRACE"
|
||||
[[ "$url" == "$METADATA_URL" ]] || return 97
|
||||
[[ "$FETCH_STATUS" == 0 ]] || return "$FETCH_STATUS"
|
||||
cat "$METADATA_FILE"
|
||||
}
|
||||
wget() { return 97; }
|
||||
dialog() {
|
||||
local arg previous="" name
|
||||
for arg in "$@"; do
|
||||
name="${arg%%│*}"
|
||||
name="${name%"${name##*[![:space:]]}"}"
|
||||
if [[ "$name" == "$MENU_NAME" ]]; then
|
||||
printf '%s' "$previous" >&2
|
||||
return 0
|
||||
fi
|
||||
previous="$arg"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
'''
|
||||
if not has_curl:
|
||||
script += "\n_has_curl() { return 1; }\n"
|
||||
script += extract_function(path, function)
|
||||
script += f"\n{function} || exit $?\n"
|
||||
script += 'printf \'%s\\0\' ' + " ".join(
|
||||
f'"${{{field}:-}}"' for field in FIELDS
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c", script],
|
||||
env=env, capture_output=True, timeout=10,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
values = result.stdout.decode().split("\0")
|
||||
self.assertEqual(len(values), len(FIELDS) + 1, result.stdout)
|
||||
selected = dict(zip(FIELDS, values))
|
||||
self.assertEqual(selected["ISO_PATH"], str(iso_dir / selected["ISO_FILE"]))
|
||||
return selected, trace.read_text().splitlines()
|
||||
|
||||
def test_debian13_dvd_uses_fixed_official_archive(self):
|
||||
selected, calls = self.run_selector(
|
||||
LINUX, "select_linux_iso_official", menu_name="Debian 13",
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-dvd/"
|
||||
"debian-13.6.0-amd64-DVD-1.iso")
|
||||
self.assertEqual(selected["ISO_TYPE"], "Desktop")
|
||||
self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-DVD-1.iso")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_debian13_netinst_uses_fixed_official_archive(self):
|
||||
selected, calls = self.run_selector(
|
||||
LINUX, "select_linux_iso_official", menu_name="Debian 13 Netinst",
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-cd/"
|
||||
"debian-13.6.0-amd64-netinst.iso")
|
||||
self.assertEqual(selected["ISO_TYPE"], "CLI")
|
||||
self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-netinst.iso")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_omv_metadata_unavailable_uses_verified_fallback(self):
|
||||
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
|
||||
for has_curl, fetch_status, metadata in (
|
||||
(False, 0, ""), (True, 22, ""),
|
||||
(True, 0, "<html>No release links</html>"),
|
||||
):
|
||||
with self.subTest(has_curl=has_curl, fetch_status=fetch_status):
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_omv_iso", metadata_url=url, has_curl=has_curl,
|
||||
fetch_status=fetch_status, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
url + "8.3.1/openmediavault_8.3.1-amd64.iso/download")
|
||||
self.assertEqual(selected["ISO_NAME"], "OpenMediaVault 8.3.1")
|
||||
self.assertEqual(selected["ISO_FILE"], "openmediavault_8.3.1-amd64.iso")
|
||||
self.assertEqual(calls, [url] if has_curl else [])
|
||||
|
||||
def test_rockstor_resolves_official_landing_without_directory_listing(self):
|
||||
# Exact installer anchor excerpt from https://rockstor.com/dls/index.html.
|
||||
url = "https://rockstor.com/dls/index.html"
|
||||
metadata = (
|
||||
'<a href="https://rockstor.com/downloads/installer/leap/15.6/x86_64/'
|
||||
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso">'
|
||||
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso</a> <br/>'
|
||||
)
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
|
||||
self.assertEqual(selected["ISO_FILE"],
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
|
||||
self.assertEqual(selected["HN"], "Rockstor")
|
||||
self.assertEqual(calls, [url])
|
||||
|
||||
def test_rockstor_metadata_unavailable_uses_verified_fallback(self):
|
||||
url = "https://rockstor.com/dls/index.html"
|
||||
for has_curl, fetch_status, metadata in (
|
||||
(False, 0, ""), (True, 22, ""),
|
||||
(True, 0, "<html>No installer links</html>"),
|
||||
):
|
||||
with self.subTest(has_curl=has_curl, fetch_status=fetch_status):
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_rockstor_iso", metadata_url=url, has_curl=has_curl,
|
||||
fetch_status=fetch_status, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
|
||||
self.assertEqual(selected["ISO_NAME"], "Rockstor")
|
||||
self.assertEqual(selected["ISO_FILE"],
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
|
||||
self.assertEqual(calls, [url] if has_curl else [])
|
||||
|
||||
def test_omv_current_metadata_resolution_is_preserved(self):
|
||||
# Reduced release links from the official SourceForge ISO index.
|
||||
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
|
||||
metadata = (
|
||||
'<a href="/projects/openmediavault/files/iso/8.3.1/" '
|
||||
'title="Click to enter 8.3.1">8.3.1</a>\n'
|
||||
'<a href="/projects/openmediavault/files/iso/7.4.17/" '
|
||||
'title="Click to enter 7.4.17">7.4.17</a>'
|
||||
)
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
url + "8.3.1/openmediavault_8.3.1-amd64.iso/download")
|
||||
self.assertEqual(calls, [url])
|
||||
|
||||
def test_omv_detected_release_takes_precedence_over_fallback(self):
|
||||
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
|
||||
# Synthetic ordering case: must execute the real version parser.
|
||||
metadata = ('<a href="/projects/openmediavault/files/iso/8.10.0/">new</a>'
|
||||
'<a href="/projects/openmediavault/files/iso/8.9.0/">old</a>')
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
url + "8.10.0/openmediavault_8.10.0-amd64.iso/download")
|
||||
self.assertEqual(calls, [url])
|
||||
|
||||
def test_rockstor_retains_generic_x86_64_leap156_edition(self):
|
||||
url = "https://rockstor.com/dls/index.html"
|
||||
# Synthetic ordering / edition cases; not claims of published images.
|
||||
metadata = "\n".join((
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso",
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.9.0-0.install.iso",
|
||||
"Rockstor-Leap15.6-generic.aarch64-9.0.0-0.install.iso",
|
||||
"Rockstor-Leap16.0-generic.x86_64-9.0.0-0.install.iso",
|
||||
"Rockstor-Leap15.5-generic.x86_64-9.0.0-0.install.iso",
|
||||
))
|
||||
selected, calls = self.run_selector(
|
||||
NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata,
|
||||
)
|
||||
self.assertEqual(selected["ISO_URL"],
|
||||
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
|
||||
"Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso")
|
||||
self.assertEqual(calls, [url])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Validate complete catalogs without rejecting intentional English fallback."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py"
|
||||
|
||||
|
||||
class WebDocsCatalogTests(unittest.TestCase):
|
||||
def run_check(self, root, *options):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root),
|
||||
"--languages", "de", *options],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def write_catalog(self, root, locale, content):
|
||||
file = root / locale / "common.json"
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(json.dumps(content), encoding="utf-8")
|
||||
|
||||
def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."})
|
||||
self.write_catalog(root, "de", {"title": "Aktualisierungen"})
|
||||
partial = self.run_check(root)
|
||||
self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr)
|
||||
self.assertIn("missing=1", partial.stdout)
|
||||
complete = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr)
|
||||
self.assertIn("schema differs", complete.stdout)
|
||||
|
||||
def test_translated_code_is_rejected_in_both_modes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"body": "Run <code>apt update</code>."})
|
||||
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> aus."})
|
||||
for options in ((), ("--require-complete",)):
|
||||
with self.subTest(options=options):
|
||||
result = self.run_check(root, *options)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
|
||||
def test_bare_commands_and_operators_are_not_prose(self):
|
||||
examples = [
|
||||
("Restore with pct restore.", "Mit pct restart wiederherstellen."),
|
||||
("pveversion | grep pve-manager", "pveversion grep pve-manager"),
|
||||
("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
self.write_catalog(root, "en", {"body": source})
|
||||
self.write_catalog(root, "de", {"body": target})
|
||||
result = self.run_check(root)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("literal command or identifier", result.stdout)
|
||||
|
||||
def test_title_separator_can_change_without_relaxing_body_pipes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}})
|
||||
self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}})
|
||||
result = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}})
|
||||
self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}})
|
||||
result = self.run_check(root)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
|
||||
def test_shipped_web_catalogs_keep_literal_contracts(self):
|
||||
messages = SCRIPT.parents[2] / "web" / "messages"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_literal_metadata_is_required_but_labels_can_be_localized(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"})
|
||||
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"})
|
||||
invalid = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr)
|
||||
self.assertIn("rendering metadata differ", invalid.stdout)
|
||||
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"})
|
||||
valid = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Exercise the static-export language repair on real HTML files."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_command_descriptions import require_node
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = ROOT / "web/scripts/localize-export.mjs"
|
||||
|
||||
|
||||
class WebExportLanguageTests(unittest.TestCase):
|
||||
def test_locales_nested_pages_preserved_content_and_idempotence(self):
|
||||
node = require_node()
|
||||
html = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"]
|
||||
for locale in locales:
|
||||
file = root / "out" / locale / "docs/monitor/index.html"
|
||||
file.parent.mkdir(parents=True)
|
||||
file.write_text(html)
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
file = root / relative
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(html)
|
||||
result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("8 localized pages", result.stdout)
|
||||
for locale in locales:
|
||||
actual = (root / "out" / locale / "docs/monitor/index.html").read_text()
|
||||
self.assertEqual(actual, html.replace('<html lang="en"', f'<html lang="{locale}"', 1))
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
self.assertEqual((root / relative).read_text(), html)
|
||||
tracked = root / "out/de/docs/monitor/index.html"
|
||||
before = tracked.stat().st_mtime_ns
|
||||
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertEqual(tracked.stat().st_mtime_ns, before)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Render the real ZRAM CLI paths in isolated files with host effects stubbed."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
DETAILS = {
|
||||
"version": ("Cannot determine installed systemd-zram-generator version", "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator"),
|
||||
"mismatch": ("Existing ZRAM profile is active but does not match its configuration", "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii"),
|
||||
"stop": ("Could not stop existing dev-zram0.swap", "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap"),
|
||||
"directory": ("Could not create ZRAM generator directory", "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM"),
|
||||
"config": ("Could not write ZRAM generator configuration", "Nepodarilo sa zapísať konfiguráciu generátora ZRAM"),
|
||||
"marker": ("Could not write ZRAM package ownership state", "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux"),
|
||||
"start": ("Could not activate dev-zram0.swap", "Nepodarilo sa aktivovať jednotku dev-zram0.swap"),
|
||||
"verify": ("Active ZRAM algorithm, capacity or swap sources did not match the profile", "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu"),
|
||||
}
|
||||
REFUSAL = "Another ZRAM generator configuration exists; refusing to overwrite it:"
|
||||
REFUSAL_SK = "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:"
|
||||
|
||||
|
||||
def function(path, name):
|
||||
text = path.read_text()
|
||||
start = text.index(name + "() {")
|
||||
return text[start:text.index("\n}\n", start) + 3]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
set -u
|
||||
BASE_DIR="$FIXTURE/state"
|
||||
LANG_DIR="$REPO/lang"
|
||||
mkdir -p "$BASE_DIR" "$FIXTURE/etc/systemd/zram-generator.conf.d" "$FIXTURE/proc"
|
||||
CONFIG="$FIXTURE/etc/systemd/zram-generator.conf.d/90-proxmenux.conf"
|
||||
MARKER="$BASE_DIR/zram-swap.state"
|
||||
printf 'Filename\tType\tSize\tUsed\tPriority\n' > "$FIXTURE/proc/swaps"
|
||||
printf '/swapfile none swap sw 0 0\n' > "$FIXTURE/etc/fstab"
|
||||
: > "$FIXTURE/calls"
|
||||
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
|
||||
pmx_journal_context() { :; }
|
||||
msg_error() { printf '%s\n' "$*"; }
|
||||
msg_success() { printf '%s\n' "$*"; }
|
||||
register_tool() { record register "$@"; }
|
||||
pmx_install_pkg() { record package-install; return 1; }
|
||||
apt-get() { record apt "$@"; return 1; }
|
||||
dpkg-query() {
|
||||
record dpkg-query
|
||||
case "$*" in
|
||||
*'${Status}'*) printf 'install ok installed\n';;
|
||||
*'${Version}'*) [[ "$CASE" == version ]] || printf '1.2.0-1\n';;
|
||||
esac
|
||||
}
|
||||
dpkg() { return 0; }
|
||||
install() { record directory; [[ "$CASE" != directory ]]; }
|
||||
pmx_write_file() {
|
||||
record write "$1"
|
||||
if [[ "$CASE" == config && "$1" == "$CONFIG" ]] ||
|
||||
[[ "$CASE" == marker && "$1" == "$MARKER" ]]; then
|
||||
cat >/dev/null
|
||||
return 1
|
||||
fi
|
||||
cat > "$1"
|
||||
}
|
||||
pmx_remove_file() { record remove "$1"; rm -f -- "$1"; }
|
||||
pmx_record_uninstall() { record record-uninstall; }
|
||||
swapoff() { record swapoff; return 0; }
|
||||
systemctl() {
|
||||
record systemctl "$@"
|
||||
[[ "$CASE" != start || "$1" != start ]] &&
|
||||
[[ "$CASE" != stop || "$1" != stop ]]
|
||||
}
|
||||
source "$REPO/scripts/post_install/zram_profile_helpers.sh"
|
||||
zram_profile_verify_active() { [[ "$CASE" != verify && "$CASE" != mismatch ]]; }
|
||||
if [[ "$CASE" == mismatch || "$CASE" == stop ]]; then
|
||||
printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
|
||||
[[ "$CASE" != mismatch ]] || zram_render_generator_config true > "$CONFIG"
|
||||
printf 'installed_by_proxmenux=false\n' > "$MARKER"
|
||||
printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$FIXTURE/proc/swaps"
|
||||
fi
|
||||
if [[ "$CASE" == foreign ]]; then
|
||||
printf '[zram1]\nzram-size = ram / 5\n' > "$FIXTURE/etc/systemd/zram-generator.conf"
|
||||
fi
|
||||
if [[ "$CASE" == unreadable ]]; then
|
||||
zram_foreign_generator_config() { return 2; }
|
||||
fi
|
||||
'''
|
||||
|
||||
|
||||
class ZramLocalizationTests(unittest.TestCase):
|
||||
def test_missing_optional_translation_uses_original_english(self):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-fallback-") as folder:
|
||||
(Path(folder) / 'xx.json').write_text('{}')
|
||||
script = function(ROOT / "scripts/utils.sh", "translate") + '\ntranslate "$1"\n'
|
||||
result = subprocess.run(["bash", "-c", script, "bash", DETAILS['version'][0]],
|
||||
text=True, capture_output=True, timeout=15,
|
||||
env={**os.environ, 'LANGUAGE': 'xx', 'LANG_DIR': folder})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), DETAILS['version'][0])
|
||||
|
||||
def run_case(self, case, language="sk"):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-i18n-") as folder:
|
||||
directory = Path(folder)
|
||||
installer = function(ROOT / "scripts/post_install/customizable_post_install.sh", "configure_zram_swap")
|
||||
installer = installer.replace("/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", folder + "/etc/systemd/zram-generator.conf.d/90-proxmenux.conf")
|
||||
installer = installer.replace("/proc/swaps", folder + "/proc/swaps").replace("/etc/fstab", folder + "/etc/fstab")
|
||||
installer = installer.replace('zram_foreign_generator_config "$config"', 'zram_foreign_generator_config "$config" "$FIXTURE"')
|
||||
installer = installer.replace('zram_list_foreign_systemd_zram_units)', 'zram_list_foreign_systemd_zram_units "$FIXTURE")')
|
||||
script = HARNESS + function(ROOT / "scripts/utils.sh", "translate") + installer + "\nconfigure_zram_swap\n"
|
||||
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
|
||||
env={**os.environ, "REPO": str(ROOT), "FIXTURE": folder, "CASE": case, "LANGUAGE": language}, timeout=15)
|
||||
calls = (directory / "calls").read_text().splitlines()
|
||||
files = {str(p.relative_to(directory)): p.read_text() for p in directory.rglob("*") if p.is_file()}
|
||||
return result, calls, files
|
||||
|
||||
def test_all_activation_details_are_localized_and_english_is_preserved(self):
|
||||
for case, (english, slovak) in DETAILS.items():
|
||||
for language, expected in (("sk", slovak), ("en", english)):
|
||||
with self.subTest(case=case, language=language):
|
||||
result, calls, files = self.run_case(case, language)
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
|
||||
if case in ("version", "mismatch"):
|
||||
self.assertTrue(all(call == "dpkg-query" for call in calls), calls)
|
||||
elif case == "stop":
|
||||
self.assertEqual(files["state/zram-swap.state"], "installed_by_proxmenux=false\n")
|
||||
self.assertIn("# ProxMenux ZRAM profile", files["etc/systemd/zram-generator.conf.d/90-proxmenux.conf"])
|
||||
else:
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
self.assertFalse(any(call.startswith(("apt ", "package-install", "register ")) for call in calls), calls)
|
||||
|
||||
def test_foreign_generator_is_refused_in_both_languages_without_any_effect(self):
|
||||
for language, expected in (("sk", REFUSAL_SK), ("en", REFUSAL)):
|
||||
result, calls, files = self.run_case("foreign", language)
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
self.assertIn("/etc/systemd/zram-generator.conf", result.stdout)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(files["etc/systemd/zram-generator.conf"], "[zram1]\nzram-size = ram / 5\n")
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
|
||||
self.assertEqual(files["proc/swaps"], "Filename\tType\tSize\tUsed\tPriority\n")
|
||||
|
||||
def test_failed_inspection_is_not_mistaken_for_no_conflict(self):
|
||||
result, calls, files = self.run_case("unreadable")
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn("Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", result.stdout)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
|
||||
def test_uninstall_label_and_cancel_do_not_call_uninstall(self):
|
||||
for language, label in (("sk", "Profil swapu ZRAM"), ("en", "ZRAM swap profile")):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-menu-") as folder:
|
||||
tools = Path(folder) / "tools.json"
|
||||
tools.write_text('{"zram_swap":true}')
|
||||
script = function(ROOT / "scripts/utils.sh", "translate")
|
||||
script += function(ROOT / "scripts/post_install/uninstall-tools.sh", "show_uninstall_menu")
|
||||
script += '\nensure_tools_json() { :; }; migrate_installed_tools() { :; }\nuninstall_zram_swap() { printf forbidden; return 1; }\ndialog() { printf "%s\\n" "$@" > "$MENU_CAPTURE"; return 1; }\nshow_uninstall_menu\n'
|
||||
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
|
||||
env={**os.environ, "LANGUAGE": language, "LANG_DIR": str(ROOT / "lang"), "TOOLS_JSON": str(tools), "MENU_CAPTURE": folder + "/dialog.txt"}, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(label, (Path(folder) / "dialog.txt").read_text())
|
||||
self.assertNotIn("forbidden", result.stdout + result.stderr)
|
||||
self.assertEqual(json.loads(tools.read_text()), {"zram_swap": True})
|
||||
@@ -32,7 +32,11 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Third-party apt repos on the runner (Google Chrome, Microsoft)
|
||||
# sometimes serve a hash-mismatched index and abort the refresh.
|
||||
# They are irrelevant here, so a failed refresh must not stop the
|
||||
# build: python3 comes from the Ubuntu repos, which fetch fine.
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
@@ -43,6 +47,26 @@ jobs:
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Set up Python 3.11 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify bundled Python 3.11 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Set up Python 3.13 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Verify bundled Python 3.13 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
|
||||
@@ -32,7 +32,11 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Third-party apt repos on the runner (Google Chrome, Microsoft)
|
||||
# sometimes serve a hash-mismatched index and abort the refresh.
|
||||
# They are irrelevant here, so a failed refresh must not stop the
|
||||
# build: python3 comes from the Ubuntu repos, which fetch fine.
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
@@ -43,6 +47,26 @@ jobs:
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Set up Python 3.11 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify bundled Python 3.11 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Set up Python 3.13 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Verify bundled Python 3.13 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
|
||||
@@ -30,7 +30,11 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Third-party apt repos on the runner (Google Chrome, Microsoft)
|
||||
# sometimes serve a hash-mismatched index and abort the refresh.
|
||||
# They are irrelevant here, so a failed refresh must not stop the
|
||||
# build: python3 comes from the Ubuntu repos, which fetch fine.
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
@@ -41,6 +45,26 @@ jobs:
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Set up Python 3.11 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify bundled Python 3.11 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Set up Python 3.13 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Verify bundled Python 3.13 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
|
||||
@@ -32,7 +32,11 @@ jobs:
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# Third-party apt repos on the runner (Google Chrome, Microsoft)
|
||||
# sometimes serve a hash-mismatched index and abort the refresh.
|
||||
# They are irrelevant here, so a failed refresh must not stop the
|
||||
# build: python3 comes from the Ubuntu repos, which fetch fine.
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
@@ -43,6 +47,26 @@ jobs:
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Set up Python 3.11 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Verify bundled Python 3.11 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Set up Python 3.13 validation runtime
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Verify bundled Python 3.13 runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
|
||||
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
name: Build i18n messages
|
||||
|
||||
# Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||
# against the English source whenever the source changes.
|
||||
#
|
||||
# The Monitor's i18n layer (AppImage/lib/i18n/provider.tsx) does its own
|
||||
# runtime fallback (locale → en → key), so this workflow doesn't break
|
||||
# anything if it misses a key: it just eliminates the visible-English
|
||||
# blocks in non-en locales.
|
||||
#
|
||||
# Guardrails baked into build_i18n_messages.py:
|
||||
# - Never overwrites a key whose target value differs from EN (i.e.
|
||||
# already translated by a human). This is what makes it safe to
|
||||
# include sk in the default set: Vaso73's curated strings are
|
||||
# protected end-to-end; auto only fills keys still on the EN
|
||||
# fallback.
|
||||
# - `{placeholder}` tokens are protected end-to-end.
|
||||
#
|
||||
# Triggers:
|
||||
# - push to develop touching AppImage/messages/en/common.json
|
||||
# - manual via workflow_dispatch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
# Only en/ triggers the run — it's the source of truth. Other
|
||||
# locales are destinations, and the bot auto-commits them at
|
||||
# the end of every run; if they were in the trigger too, each
|
||||
# auto-commit would fire another (empty) run.
|
||||
# Manual edits to a curated locale that empty a key for the
|
||||
# workflow to refill are the exception — dispatch this
|
||||
# workflow manually from Actions in that case, or piggy-back
|
||||
# a trivial en/ change onto the commit.
|
||||
- 'AppImage/messages/en/common.json'
|
||||
- '.github/scripts/build_i18n_messages.py'
|
||||
- '.github/workflows/build-i18n-messages.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
refresh:
|
||||
description: 'Re-translate every key (overwrites human translations!)'
|
||||
type: boolean
|
||||
default: false
|
||||
languages:
|
||||
description: 'Comma-separated locales. Default: es,de,fr,it,pt,sk,sv (guardrail protects Vaso73 sk).'
|
||||
default: 'es,de,fr,it,pt,sk,sv'
|
||||
|
||||
# Prevent two runs from racing on the same branch and fighting over the
|
||||
# auto-commit. cancel-in-progress:false because a full first-run may take
|
||||
# ~30 min for the initial bootstrap and interrupting mid-flight would
|
||||
# waste the calls already made.
|
||||
concurrency:
|
||||
group: build-i18n-messages-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # auto-commit AppImage/messages/*/common.json to develop
|
||||
# First-run bootstrap of ~5 locales × 3.8k keys can take a while at
|
||||
# 0.15s/call with rate-limit backoffs. 90 min headroom.
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
# Full history so the auto-commit doesn't drift when another
|
||||
# push landed between trigger and this job start.
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install googletrans
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Same pinning as build-translation-cache.yml so the two
|
||||
# workflows share behavior. Bump both in lockstep.
|
||||
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||
|
||||
- name: Translate missing keys
|
||||
run: |
|
||||
REFRESH_FLAG=""
|
||||
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||
REFRESH_FLAG="--refresh"
|
||||
fi
|
||||
LANGS="${{ github.event.inputs.languages }}"
|
||||
# Keep this fallback in sync with the workflow_dispatch input
|
||||
# default above AND with DEFAULT_LANGUAGES in the Python script —
|
||||
# `push` triggers hit this branch (inputs are empty on push).
|
||||
LANGS="${LANGS:-es,de,fr,it,pt,sk,sv}"
|
||||
python .github/scripts/build_i18n_messages.py \
|
||||
--source AppImage/messages/en/common.json \
|
||||
--messages-dir AppImage/messages \
|
||||
--languages "$LANGS" \
|
||||
--provider googletrans \
|
||||
$REFRESH_FLAG
|
||||
|
||||
- name: Commit + push if changed
|
||||
run: |
|
||||
if git diff --quiet -- AppImage/messages/; then
|
||||
echo "No translation changes — skipping commit."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add AppImage/messages/
|
||||
git commit -m "chore(i18n): auto-fill missing translations in messages/{locale}/common.json
|
||||
|
||||
Source: ${GITHUB_SHA::7}
|
||||
Triggered by: ${{ github.event_name }}"
|
||||
# Rebase-and-retry against develop: between the initial
|
||||
# checkout and this push another workflow (e.g.
|
||||
# build-translation-cache auto-commit) or a manual push can
|
||||
# land on develop first, and a naked push fails with
|
||||
# non-fast-forward — losing the freshly generated translation
|
||||
# commit. Rebasing the local i18n commit on top of the newer
|
||||
# HEAD is safe: paths don't overlap with cache/script
|
||||
# workflows, and the same-branch collisions between two i18n
|
||||
# runs are already blocked by the concurrency group above.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin develop; then
|
||||
echo "push succeeded on attempt ${attempt}"
|
||||
exit 0
|
||||
fi
|
||||
echo "push attempt ${attempt} failed, retrying..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $(( attempt * 3 ))
|
||||
done
|
||||
echo "push failed after 5 attempts"
|
||||
exit 1
|
||||
@@ -15,9 +15,15 @@ on:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/remote/*.sh'
|
||||
- 'oci/remote/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- '.github/scripts/build_translation_cache.py'
|
||||
- '.github/workflows/build-translation-cache.yml'
|
||||
workflow_dispatch:
|
||||
@@ -79,6 +85,11 @@ jobs:
|
||||
--extra-file menu \
|
||||
--extra-file install_proxmenux.sh \
|
||||
--extra-file install_proxmenux_beta.sh \
|
||||
--extra-dir oci/remote \
|
||||
--python-dir scripts \
|
||||
--python-dir oci/src \
|
||||
--python-dir oci/remote \
|
||||
--catalog-dir oci/catalog \
|
||||
--output-dir lang \
|
||||
--provider googletrans \
|
||||
$REFRESH_FLAG
|
||||
@@ -96,4 +107,23 @@ jobs:
|
||||
|
||||
Source: ${GITHUB_SHA::7}
|
||||
Triggered by: ${{ github.event_name }}"
|
||||
git push origin develop
|
||||
# Rebase-and-retry against develop: between the initial
|
||||
# checkout and this push another workflow (e.g.
|
||||
# build-i18n-messages auto-commit) or a manual push can land
|
||||
# on develop first, and a naked push fails with
|
||||
# non-fast-forward — losing the freshly built cache commit.
|
||||
# Paths don't overlap with the other workflows, and
|
||||
# same-branch collisions between two cache runs are already
|
||||
# blocked by the concurrency group.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin develop; then
|
||||
echo "push succeeded on attempt ${attempt}"
|
||||
exit 0
|
||||
fi
|
||||
echo "push attempt ${attempt} failed, retrying..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $(( attempt * 3 ))
|
||||
done
|
||||
echo "push failed after 5 attempts"
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Build web documentation translations
|
||||
|
||||
on:
|
||||
# Source and locale corrections are reviewed together. An automatic run
|
||||
# would treat the changed English fingerprints as stale translations and
|
||||
# overwrite those reviewed locale strings with provider output.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
languages:
|
||||
description: 'Comma-separated locales'
|
||||
default: 'es,de,fr,it,pt,sk,sv'
|
||||
section:
|
||||
description: 'File or directory below web/messages/en'
|
||||
default: '.'
|
||||
max_files:
|
||||
description: 'Maximum pending files per locale; 0 means all'
|
||||
default: '0'
|
||||
refresh:
|
||||
description: 'Overwrite existing translations in the selected scope'
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: 'Report pending coverage without writing files'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: build-web-docs-i18n-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install translation provider
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||
|
||||
- name: Build missing translations
|
||||
shell: bash
|
||||
run: |
|
||||
LANGUAGES="${{ github.event.inputs.languages }}"
|
||||
LANGUAGES="${LANGUAGES:-es,de,fr,it,pt,sk,sv}"
|
||||
SECTION="${{ github.event.inputs.section }}"
|
||||
SECTION="${SECTION:-.}"
|
||||
MAX_FILES="${{ github.event.inputs.max_files }}"
|
||||
MAX_FILES="${MAX_FILES:-0}"
|
||||
|
||||
EXTRA_ARGS=()
|
||||
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||
EXTRA_ARGS+=(--refresh)
|
||||
fi
|
||||
if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then
|
||||
EXTRA_ARGS+=(--dry-run)
|
||||
fi
|
||||
|
||||
python .github/scripts/build_web_docs_i18n.py \
|
||||
--source-dir web/messages/en \
|
||||
--messages-dir web/messages \
|
||||
--languages "$LANGUAGES" \
|
||||
--section "$SECTION" \
|
||||
--max-files "$MAX_FILES" \
|
||||
--provider googletrans \
|
||||
--workers 4 \
|
||||
"${EXTRA_ARGS[@]}"
|
||||
|
||||
- name: Validate catalogs
|
||||
run: |
|
||||
python .github/scripts/build_web_docs_i18n.py \
|
||||
--source-dir web/messages/en \
|
||||
--messages-dir web/messages \
|
||||
--languages "${{ github.event.inputs.languages || 'es,de,fr,it,pt,sk,sv' }}" \
|
||||
--check
|
||||
|
||||
- name: Commit and push changes
|
||||
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
if git diff --quiet -- web/messages/; then
|
||||
echo "No documentation translations changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add web/messages/
|
||||
git commit -m "docs(i18n): update documentation translations"
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin develop; then
|
||||
exit 0
|
||||
fi
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $((attempt * 3))
|
||||
done
|
||||
exit 1
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Label OCI validation pull requests
|
||||
|
||||
# A pull request that changes verification.json gets the "oci-validation"
|
||||
# label, which adds it to the ProxMenux Roadmap. The pull request's code is
|
||||
# never checked out: only its list of changed files is read.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
paths:
|
||||
- 'oci/catalog/verification.json'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh api "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels" -f "labels[]=oci-validation" --silent
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Record OCI validation
|
||||
|
||||
# When an OCI validation report is validated, the report's author is recorded
|
||||
# in oci/catalog/verification.json on develop and oci/VALIDATION.md is
|
||||
# regenerated. A report is validated by the "validated" label, which only users
|
||||
# with write access can add, or by a /validated comment from a user listed in
|
||||
# .github/oci-validation-reviewers, checked by the script. The issue body and
|
||||
# the comment are read from the event file, never interpolated into a shell.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: oci-validation-record
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
record:
|
||||
if: >-
|
||||
contains(github.event.issue.labels.*.name, 'oci-validation') && (
|
||||
(github.event_name == 'issues' && github.event.label.name == 'validated') ||
|
||||
(github.event_name == 'issue_comment' && !github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/validated')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Record the report
|
||||
id: record
|
||||
run: |
|
||||
set +e
|
||||
python3 .github/scripts/oci_validation.py record --event "$GITHUB_EVENT_PATH" --message "$RUNNER_TEMP/reply.md"
|
||||
echo "code=$?" >> "$GITHUB_OUTPUT"
|
||||
- name: Commit + push
|
||||
if: steps.record.outputs.code == '0'
|
||||
env:
|
||||
APP: ${{ steps.record.outputs.app }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
git config user.name "ProxMenuxBot"
|
||||
git config user.email "bot@proxmenux.local"
|
||||
git add oci/catalog/verification.json oci/VALIDATION.md
|
||||
git commit -m "chore(oci): record the validation of $APP (#$NUMBER)"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin develop
|
||||
if git rebase origin/develop && git push origin HEAD:develop; then
|
||||
exit 0
|
||||
fi
|
||||
git rebase --abort 2>/dev/null || true
|
||||
sleep $(( attempt * 3 ))
|
||||
done
|
||||
echo "push failed after 5 attempts"
|
||||
exit 1
|
||||
- name: Reply and close
|
||||
if: steps.record.outputs.code == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
|
||||
gh issue close "$NUMBER" --repo "$GITHUB_REPOSITORY" --reason completed
|
||||
- name: Explain why it was not recorded
|
||||
if: steps.record.outputs.code == '2'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
|
||||
if [ "$GITHUB_EVENT_NAME" = issues ]; then
|
||||
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels/validated" --silent
|
||||
fi
|
||||
- name: Fail on an unexpected error
|
||||
if: steps.record.outputs.code != '0' && steps.record.outputs.code != '2'
|
||||
run: exit 1
|
||||
@@ -0,0 +1,49 @@
|
||||
name: OCI validation record
|
||||
|
||||
# verification.json is the record of OCI applications tested for real, and
|
||||
# oci/VALIDATION.md is generated from it. A pull request only adds or changes
|
||||
# the community tests credited to its own author.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'oci/catalog/verification.json'
|
||||
- 'oci/catalog/index.json'
|
||||
- 'oci/VALIDATION.md'
|
||||
- '.github/scripts/oci_validation.py'
|
||||
- '.github/workflows/oci-validation.yml'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'oci/catalog/verification.json'
|
||||
- 'oci/catalog/index.json'
|
||||
- 'oci/VALIDATION.md'
|
||||
- '.github/scripts/oci_validation.py'
|
||||
- '.github/workflows/oci-validation.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Check the record and oci/VALIDATION.md
|
||||
run: python3 .github/scripts/oci_validation.py check
|
||||
- name: Check who the pull request credits
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$BASE"
|
||||
git show "$BASE:oci/catalog/verification.json" > "$RUNNER_TEMP/base.json" 2>/dev/null || : > "$RUNNER_TEMP/base.json"
|
||||
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user