mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 20:32:15 +00:00
Compare commits
993
Commits
v1.2.6.2-beta
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88fa230e10 | ||
|
|
795f77f76f | ||
|
|
ecd94b45ec | ||
|
|
eea782e4d1 | ||
|
|
dc18463965 | ||
|
|
256d1d911d | ||
|
|
745896e617 | ||
|
|
13ac55ac82 | ||
|
|
683595aefc | ||
|
|
3d19c93c02 | ||
|
|
1753d3aaa5 | ||
|
|
7783f9a2ec | ||
|
|
e30c592217 | ||
|
|
3090f1d772 | ||
|
|
163317f727 | ||
|
|
ce8ccab4de | ||
|
|
202557f540 | ||
|
|
d4525fb13e | ||
|
|
2d1d7bcc7b | ||
|
|
7a73fa03f1 | ||
|
|
9d6c1edbc7 | ||
|
|
c2fd894083 | ||
|
|
cd6b331a84 | ||
|
|
50a1d050bc | ||
|
|
bb697c4c23 | ||
|
|
41cc933e0a | ||
|
|
b37e6610d1 | ||
|
|
2ef15f94a5 | ||
|
|
8644d5021e | ||
|
|
a8d1146e66 | ||
|
|
321ebadea3 | ||
|
|
7775a29172 | ||
|
|
20a4d08d05 | ||
|
|
a18a008108 | ||
|
|
9aa378fccb | ||
|
|
05ed3925da | ||
|
|
0c98bb2c6c | ||
|
|
7605a6a2fa | ||
|
|
0e42f658da | ||
|
|
b6006554c6 | ||
|
|
6324d41a1d | ||
|
|
ef00a34654 | ||
|
|
143c7db610 | ||
|
|
17786e0f54 | ||
|
|
5c91e1c661 | ||
|
|
f0871607fa | ||
|
|
8e1b2ccc9d | ||
|
|
b7167ac498 | ||
|
|
e916c3b8bb | ||
|
|
64e9e0e8dd | ||
|
|
7cbe92ad9f | ||
|
|
8ca445ff3b | ||
|
|
007cf78ec2 | ||
|
|
0ca936f3b4 | ||
|
|
bf27c3e1da | ||
|
|
150d15950d | ||
|
|
fd061054d4 | ||
|
|
d3005bbaff | ||
|
|
e361195abc | ||
|
|
5d2c95a22b | ||
|
|
76696a5271 | ||
|
|
2bcc0c1e61 | ||
|
|
2fd55f8c88 | ||
|
|
3c09800ab5 | ||
|
|
84d860f71b | ||
|
|
344346ff9d | ||
|
|
e1243ea2ff | ||
|
|
0a4824e2f1 | ||
|
|
a97dafacf6 | ||
|
|
eef425666a | ||
|
|
f4e7f7d525 | ||
|
|
0d4c6e6bd3 | ||
|
|
3ff9885394 | ||
|
|
a026ab5926 | ||
|
|
1dca76aaa5 | ||
|
|
dcb1d65ec8 | ||
|
|
7bb99d30ff | ||
|
|
a89eb921cc | ||
|
|
97da4321c8 | ||
|
|
11c0c683d7 | ||
|
|
6628744900 | ||
|
|
194a959492 | ||
|
|
74e5b7a4ff | ||
|
|
244181f9df | ||
|
|
e3f85b3e09 | ||
|
|
c0067d35a6 | ||
|
|
cd3d4061d0 | ||
|
|
e9272c90a0 | ||
|
|
e51553ada8 | ||
|
|
c07b328bb9 | ||
|
|
9a9d0f2379 | ||
|
|
389a07c265 | ||
|
|
c5df337db6 | ||
|
|
3e2b8d72d1 | ||
|
|
3bf4c0d82f | ||
|
|
c3024b8448 | ||
|
|
ab76ab161b | ||
|
|
f61c59d093 | ||
|
|
7f59ee5103 | ||
|
|
cef1dc7422 | ||
|
|
888b99330b | ||
|
|
af0dd74067 | ||
|
|
75a96b2658 | ||
|
|
114882fcba | ||
|
|
79c0f268fd | ||
|
|
7ec022af81 | ||
|
|
07f7f9a6a1 | ||
|
|
fec97c57d9 | ||
|
|
4ba0d6a236 | ||
|
|
96b3a919ba | ||
|
|
c628e65e2f | ||
|
|
1e08a5939b | ||
|
|
163c7c3505 | ||
|
|
eb46fffd45 | ||
|
|
a4c2c34597 | ||
|
|
aa19af66eb | ||
|
|
f19c300387 | ||
|
|
c1183bdeed | ||
|
|
19e35a567f | ||
|
|
f428dc76f0 | ||
|
|
e412d3161d | ||
|
|
13a8ac1001 | ||
|
|
f0d54fca13 | ||
|
|
d9973635fb | ||
|
|
75b13d87da | ||
|
|
ac97e2c5a6 | ||
|
|
700f72babd | ||
|
|
0ab40b4eea | ||
|
|
9940f05adf | ||
|
|
8328d5d366 | ||
|
|
7e6a942d16 | ||
|
|
03928c4277 | ||
|
|
8aefd70e52 | ||
|
|
6bee13114c | ||
|
|
6a1513a014 | ||
|
|
fb083e92cb | ||
|
|
781d2500ad | ||
|
|
98931fe4a7 | ||
|
|
46337f162f | ||
|
|
064c2a6c39 | ||
|
|
3feb5ca5cb | ||
|
|
b0215d4bbb | ||
|
|
9f3fffd249 | ||
|
|
511211f10b | ||
|
|
41081121f8 | ||
|
|
e83794b7e2 | ||
|
|
1f56d6b1ce | ||
|
|
7df9fdf381 | ||
|
|
9084df7ebd | ||
|
|
0ae3fd19f4 | ||
|
|
8d8ae53a75 | ||
|
|
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 | ||
|
|
418afe385b | ||
|
|
9ed09cc28e | ||
|
|
e3113df0a2 | ||
|
|
4261019549 | ||
|
|
5dc98e9daf | ||
|
|
488044e0b9 | ||
|
|
7320fc6ff6 | ||
|
|
398ca55247 | ||
|
|
4f415c98cf | ||
|
|
24d069e1dd | ||
|
|
3a000458b2 | ||
|
|
d5b7d386ea | ||
|
|
624ff628bd | ||
|
|
5ea0645e31 | ||
|
|
ac2bd28d25 | ||
|
|
2327f96479 | ||
|
|
ead77f0d1f | ||
|
|
52a9034676 | ||
|
|
e8e9e07e6b | ||
|
|
cb97fc4c98 | ||
|
|
75fd803443 | ||
|
|
c0a0cdca34 | ||
|
|
d75d44c601 | ||
|
|
276a116696 | ||
|
|
b1010a194a | ||
|
|
0faa781dd1 | ||
|
|
60b66c4668 | ||
|
|
393e6095fa | ||
|
|
380a9b653c | ||
|
|
1920dc2937 | ||
|
|
bfdcd4a42c | ||
|
|
139a4b4089 | ||
|
|
14e2718275 | ||
|
|
6befea0757 | ||
|
|
ed2a696c01 | ||
|
|
6968409ae5 | ||
|
|
a6ada401b5 | ||
|
|
98d8e78dcb | ||
|
|
f9fe4d2793 | ||
|
|
81cc746e19 | ||
|
|
aa4adc34a9 | ||
|
|
dc823bc21b | ||
|
|
297a41d58a | ||
|
|
72908e55aa | ||
|
|
668b557fd5 | ||
|
|
f5ce0438f9 | ||
|
|
21616fe53d | ||
|
|
8e6e9d5cab | ||
|
|
3b15e14625 | ||
|
|
89531bbab8 | ||
|
|
c13d3de023 | ||
|
|
dd9c21ed75 | ||
|
|
aaffe4e08a | ||
|
|
d3fedeb201 | ||
|
|
bd42df3ead | ||
|
|
cf7b8363da | ||
|
|
d3752f0417 | ||
|
|
64fa4f8115 | ||
|
|
88ba698758 | ||
|
|
72491b2445 | ||
|
|
8eef466949 | ||
|
|
30034e7aa3 | ||
|
|
c26eb49e2c | ||
|
|
db12972253 | ||
|
|
fac7ac5021 | ||
|
|
512b268815 | ||
|
|
e52460e991 | ||
|
|
5db3ae9c92 | ||
|
|
79bc8d33fd | ||
|
|
581eed61e5 | ||
|
|
d27dc834db | ||
|
|
2ab83c0b88 | ||
|
|
bdf9075639 | ||
|
|
65b10cea09 | ||
|
|
9147ec2f52 | ||
|
|
ba3fb1197f | ||
|
|
889409de8d | ||
|
|
43973e1d06 | ||
|
|
e1be5c22c8 | ||
|
|
79cdf814ed | ||
|
|
707a45e639 | ||
|
|
52cd21dcb1 | ||
|
|
503a946d85 | ||
|
|
ac9565fa19 | ||
|
|
f40e0a53e1 | ||
|
|
2d4b0cb746 | ||
|
|
616ba129b2 | ||
|
|
6a91487d21 | ||
|
|
8ee3b29991 | ||
|
|
52d44a7435 | ||
|
|
5e89b2bdf5 | ||
|
|
a259b945eb | ||
|
|
bf8c495d3b | ||
|
|
05790b494e | ||
|
|
a7786a6874 | ||
|
|
3d1c542a05 | ||
|
|
e587aa7d79 | ||
|
|
6a30d92ba7 | ||
|
|
3a0a7eaf14 | ||
|
|
b383fb9311 | ||
|
|
68ae23df82 | ||
|
|
f23f1912c2 | ||
|
|
692c7f7256 | ||
|
|
a079f3fc90 | ||
|
|
cdb1bd0cbf | ||
|
|
87c48830c5 | ||
|
|
ecc8017f71 | ||
|
|
fc8c23fd66 | ||
|
|
5adafeaa96 | ||
|
|
aeb55236c1 | ||
|
|
f623d29e12 | ||
|
|
9ab744e784 | ||
|
|
9fef8d89a7 | ||
|
|
85857acaf9 | ||
|
|
f5c98fb700 | ||
|
|
f79c35b038 | ||
|
|
6173c012e6 | ||
|
|
4cefc0484b | ||
|
|
ac9b8c2705 | ||
|
|
085bb084eb | ||
|
|
8f8a80785a | ||
|
|
e6d39f6015 | ||
|
|
ec5fbd668f | ||
|
|
688b7f54bc | ||
|
|
c676d883d9 | ||
|
|
6feb11ffb9 | ||
|
|
2b1ae0be72 | ||
|
|
28d9ff1c9e | ||
|
|
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 | ||
|
|
bec4606724 | ||
|
|
f3be456062 | ||
|
|
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 | ||
|
|
b4c9ffb54b | ||
|
|
2898ecbfdb | ||
|
|
5063c7a006 | ||
|
|
def0987711 | ||
|
|
b938e14fc5 | ||
|
|
248ac5dec6 | ||
|
|
52632abbd6 | ||
|
|
ada0f1109f | ||
|
|
87720f14f1 | ||
|
|
548e237096 | ||
|
|
440c3df16f | ||
|
|
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 | ||
|
|
7b2d345e83 | ||
|
|
b026332032 | ||
|
|
7215c551d4 | ||
|
|
bb817ad1f9 | ||
|
|
dad116c8b5 | ||
|
|
34c549b738 | ||
|
|
2d022a873d | ||
|
|
ee3d8db577 | ||
|
|
8169aeadce | ||
|
|
9ac9556940 | ||
|
|
8902a2574f | ||
|
|
ded2af3a57 | ||
|
|
4ea1bf4a80 | ||
|
|
1bf8dd00c9 | ||
|
|
7305713c80 | ||
|
|
a02057fd52 | ||
|
|
65d55b0f58 | ||
|
|
0d2d639795 | ||
|
|
bc913ff5eb | ||
|
|
113c31ab09 | ||
|
|
8094d69fb4 | ||
|
|
f3a3332327 | ||
|
|
5734947e17 | ||
|
|
ae22df1f9d | ||
|
|
e82216eb5b | ||
|
|
346d9b15f6 | ||
|
|
b9e5b03d6b | ||
|
|
3fc99a35b6 | ||
|
|
071d6081f8 | ||
|
|
af10486436 | ||
|
|
db1ab725d6 | ||
|
|
85b2d2114a | ||
|
|
0cd2efdd4e | ||
|
|
f40910125c | ||
|
|
98da85bf00 | ||
|
|
ddb0b5a400 | ||
|
|
f641bab0e0 | ||
|
|
cbd4aff7a4 | ||
|
|
8efccc3d03 | ||
|
|
5b789d2433 | ||
|
|
bfe7794d98 | ||
|
|
ff51c2239e | ||
|
|
f7239889ca | ||
|
|
7a3f713670 | ||
|
|
fd8dd11643 | ||
|
|
dd18ba6cd6 | ||
|
|
8cf19ad79b | ||
|
|
7899ffb43d | ||
|
|
3cff8caa84 | ||
|
|
b23eaf1713 | ||
|
|
b2294a6c59 | ||
|
|
3df04b8428 | ||
|
|
8a8deed134 | ||
|
|
cb3ff72699 | ||
|
|
7c64f7427a | ||
|
|
f1c15a1cbe | ||
|
|
a26876cff9 | ||
|
|
0fd051a498 | ||
|
|
4eccbd9269 | ||
|
|
c5752fe582 | ||
|
|
c8f7a4fe3e | ||
|
|
7198c19da8 | ||
|
|
acc426e723 | ||
|
|
662793d7c2 | ||
|
|
e704210685 | ||
|
|
33c8ff5e7e | ||
|
|
dc26a872fd | ||
|
|
af41036c18 | ||
|
|
ef6d024a4a | ||
|
|
15e819385f | ||
|
|
50b4dc33fd | ||
|
|
26bc278b34 | ||
|
|
7ba90791ea | ||
|
|
c28b22fb59 | ||
|
|
7640762462 | ||
|
|
891c68ba42 | ||
|
|
7aa9840414 | ||
|
|
09e4b81d65 | ||
|
|
80c1cf4475 | ||
|
|
26d30481b5 | ||
|
|
4d32422fef | ||
|
|
43a655a57a | ||
|
|
93900b872e | ||
|
|
ac4be7031f | ||
|
|
102ac40226 | ||
|
|
7f90154410 | ||
|
|
013def197a | ||
|
|
baaff2725b | ||
|
|
8e7588f372 | ||
|
|
8507097a9a | ||
|
|
ed5f0e8393 | ||
|
|
49efa18f97 | ||
|
|
a4537be773 | ||
|
|
8f62859971 | ||
|
|
fb1517807c | ||
|
|
685b37bc43 | ||
|
|
5867d467f3 | ||
|
|
dd2a9f611d | ||
|
|
30e6e64d4f | ||
|
|
f9373f8777 | ||
|
|
cad825ae7e | ||
|
|
5b4366fa82 | ||
|
|
1df63be887 | ||
|
|
b599d1cf26 | ||
|
|
1919e62646 | ||
|
|
38d445a379 | ||
|
|
150494bce1 | ||
|
|
2424545532 | ||
|
|
e9c5a12702 | ||
|
|
3dcf6cd661 | ||
|
|
d91599d354 | ||
|
|
97bf9e24e5 | ||
|
|
9a1642b53c | ||
|
|
c90e365d1f | ||
|
|
9ae3a54e8b | ||
|
|
cc66939095 | ||
|
|
56da2ac880 | ||
|
|
2630e4f016 | ||
|
|
966e9799f2 | ||
|
|
ae3b93a3c1 | ||
|
|
459ea9c6db | ||
|
|
636983b697 | ||
|
|
986301d152 | ||
|
|
5b41cbfbe8 | ||
|
|
17a72753bb | ||
|
|
333d03a51e | ||
|
|
f59323455b | ||
|
|
ac30502b67 |
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"repository": "MacRimi/ProxMenux",
|
"repository": "MacRimi/ProxMenux",
|
||||||
"updatedAt": "2026-09-02T08:48:44.825Z",
|
"updatedAt": "2026-10-26T04:36:49.492Z",
|
||||||
"backfilledAt": "2026-07-22T18:09:20.834Z",
|
"backfilledAt": "2026-07-22T18:09:20.834Z",
|
||||||
"totals": {
|
"totals": {
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"clones": 22221
|
"clones": 59792
|
||||||
},
|
},
|
||||||
"assets": {},
|
"assets": {},
|
||||||
"traffic": {
|
"traffic": {
|
||||||
@@ -133,6 +133,218 @@
|
|||||||
"2026-09-01": {
|
"2026-09-01": {
|
||||||
"count": 552,
|
"count": 552,
|
||||||
"uniques": 396
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3369,7 +3581,385 @@
|
|||||||
"stars": 2926,
|
"stars": 2926,
|
||||||
"forks": 152,
|
"forks": 152,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"clones": 22221
|
"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": 59792
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-10-26",
|
||||||
|
"stars": 3234,
|
||||||
|
"forks": 175,
|
||||||
|
"downloads": 0,
|
||||||
|
"clones": 59792
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
|
|||||||
# provider, then restore the exact source spelling afterwards. Keep longer
|
# provider, then restore the exact source spelling afterwards. Keep longer
|
||||||
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
|
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
|
||||||
PROTECTED_TECHNICAL_TERMS = (
|
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",
|
"google/gasket-driver",
|
||||||
"feranick/gasket-driver",
|
"feranick/gasket-driver",
|
||||||
"libedgetpu1-std",
|
"libedgetpu1-std",
|
||||||
@@ -90,6 +102,12 @@ PROTECTED_TECHNICAL_TERMS = (
|
|||||||
"WebUI",
|
"WebUI",
|
||||||
"Intel",
|
"Intel",
|
||||||
"CUDA",
|
"CUDA",
|
||||||
|
"ROCm",
|
||||||
|
"Vulkan",
|
||||||
|
"SYCL",
|
||||||
|
"QSV",
|
||||||
|
"OpenVINO",
|
||||||
|
"OpenCL",
|
||||||
"KFD",
|
"KFD",
|
||||||
"GPU",
|
"GPU",
|
||||||
"CPU",
|
"CPU",
|
||||||
@@ -102,6 +120,9 @@ PROTECTED_TECHNICAL_TERMS = (
|
|||||||
# Open Container Initiative. Read as a word it becomes "BEC" in French,
|
# Open Container Initiative. Read as a word it becomes "BEC" in French,
|
||||||
# which named nothing, across thirty-one strings of the OCI section.
|
# which named nothing, across thirty-one strings of the OCI section.
|
||||||
"OCI manager Apps",
|
"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",
|
"OCI",
|
||||||
"beta",
|
"beta",
|
||||||
# Debian release names. "Bookworm" is a bird and a reader of books, and
|
# Debian release names. "Bookworm" is a bird and a reader of books, and
|
||||||
|
|||||||
@@ -73,6 +73,24 @@ TECHNICAL_TERMS = (
|
|||||||
"DKMS",
|
"DKMS",
|
||||||
"SSH",
|
"SSH",
|
||||||
"API",
|
"API",
|
||||||
|
"Flask",
|
||||||
|
"SQLite",
|
||||||
|
"Prometheus",
|
||||||
|
"Grafana",
|
||||||
|
"Uptime Kuma",
|
||||||
|
"PWA",
|
||||||
|
"Fail2Ban",
|
||||||
|
"Tailscale",
|
||||||
|
"JavaScript",
|
||||||
|
"TypeScript",
|
||||||
|
"Next.js",
|
||||||
|
"Coral",
|
||||||
|
"rclone",
|
||||||
|
"journald",
|
||||||
|
"gasket",
|
||||||
|
"apex",
|
||||||
|
"Alpine",
|
||||||
|
"OpenVINO",
|
||||||
)
|
)
|
||||||
|
|
||||||
PROTECTED_PATTERNS = (
|
PROTECTED_PATTERNS = (
|
||||||
@@ -88,9 +106,13 @@ PROTECTED_PATTERNS = (
|
|||||||
re.compile(r"(?<![A-Za-z0-9<])/(?:[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"\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_]*"),
|
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(
|
LITERAL_TAG_RE = re.compile(
|
||||||
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?</\1>",
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,16 +131,23 @@ ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
|||||||
BARE_SENTINELS = False
|
BARE_SENTINELS = False
|
||||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||||
NON_TRANSLATABLE_KEYS = {
|
NON_TRANSLATABLE_KEYS = {
|
||||||
|
"accent",
|
||||||
|
"categoryslug",
|
||||||
"command",
|
"command",
|
||||||
"code",
|
"code",
|
||||||
"href",
|
"href",
|
||||||
"icon",
|
"icon",
|
||||||
|
"iconbg",
|
||||||
"id",
|
"id",
|
||||||
|
"menucmd",
|
||||||
"path",
|
"path",
|
||||||
"route",
|
"route",
|
||||||
"slug",
|
"slug",
|
||||||
"src",
|
"src",
|
||||||
"url",
|
"url",
|
||||||
|
"color",
|
||||||
|
"tone",
|
||||||
|
"variant",
|
||||||
}
|
}
|
||||||
SOURCE_STATE_VERSION = 1
|
SOURCE_STATE_VERSION = 1
|
||||||
|
|
||||||
@@ -186,12 +215,34 @@ def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
|
|||||||
if not source.strip() or not re.search(r"[A-Za-z]", source):
|
if not source.strip() or not re.search(r"[A-Za-z]", source):
|
||||||
return True
|
return True
|
||||||
last = str(path[-1]).lower() if path else ""
|
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(
|
if last in NON_TRANSLATABLE_KEYS or any(
|
||||||
last.endswith(suffix)
|
last.endswith(suffix)
|
||||||
for suffix in ("url", "href", "path", "command", "code", "icon")
|
for suffix in ("url", "href", "path", "command", "code", "icon")
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
|
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
|
return True
|
||||||
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
|
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
|
||||||
return True
|
return True
|
||||||
@@ -202,6 +253,15 @@ def leaf_token(path: tuple[str | int, ...]) -> str:
|
|||||||
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
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]:
|
def source_fingerprints(source: Any) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
|
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
|
||||||
@@ -240,7 +300,7 @@ def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
|
|||||||
|
|
||||||
def replace(match: re.Match[str]) -> str:
|
def replace(match: re.Match[str]) -> str:
|
||||||
original = match.group(1)
|
original = match.group(1)
|
||||||
if original.lower() in {"code", "kbd", "pre"}:
|
if original.lower() in {"code", "codenw", "codexs", "kbd", "pre"}:
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
key = original.lower()
|
key = original.lower()
|
||||||
internal = names.get(key)
|
internal = names.get(key)
|
||||||
@@ -329,11 +389,14 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def restore_text(text: str, mapping: dict[str, str]) -> str:
|
def restore_text(text: str, mapping: dict[str, str]) -> str:
|
||||||
missing = [token for token in mapping if token not in text]
|
# 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:
|
if missing:
|
||||||
raise ValueError(f"translation provider changed protected token {missing[0]}")
|
raise ValueError(f"translation provider changed protected token {missing[0]}")
|
||||||
for token, original in mapping.items():
|
for token, original in mapping.items():
|
||||||
text = text.replace(token, original)
|
text = patterns[token].sub(lambda match: original, text)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -347,6 +410,11 @@ def validate_contract(source: str, target: str) -> None:
|
|||||||
raise ValueError("literal code changed")
|
raise ValueError("literal code changed")
|
||||||
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||||
raise ValueError("placeholder contract changed")
|
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 provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||||
@@ -822,7 +890,10 @@ def main() -> int:
|
|||||||
leaves = pending_leaves(source, target, queued_tokens)
|
leaves = pending_leaves(source, target, queued_tokens)
|
||||||
total_strings += len(iter_leaves(source))
|
total_strings += len(iter_leaves(source))
|
||||||
missing_strings += len(leaves)
|
missing_strings += len(leaves)
|
||||||
schema_changed = not schema_matches(source, target)
|
schema_changed = (
|
||||||
|
not schema_matches(source, target)
|
||||||
|
or not literal_values_match(source, target)
|
||||||
|
)
|
||||||
if leaves or schema_changed:
|
if leaves or schema_changed:
|
||||||
pending.append(
|
pending.append(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Reject foreign ELF files in a native AppDir without executing any binary."""
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
def check(directory, architecture):
|
||||||
|
expected = {'x86_64': 62, 'aarch64': 183}[architecture]
|
||||||
|
checked = 0
|
||||||
|
for path in Path(directory).rglob('*'):
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
continue
|
||||||
|
with path.open('rb') as stream:
|
||||||
|
header = stream.read(20)
|
||||||
|
if not header.startswith(b'\x7fELF'):
|
||||||
|
continue
|
||||||
|
if len(header) != 20 or header[5] not in (1, 2):
|
||||||
|
raise ValueError(f'Invalid ELF header: {path}')
|
||||||
|
machine = struct.unpack('<H' if header[5] == 1 else '>H', header[18:20])[0]
|
||||||
|
if machine != expected:
|
||||||
|
raise ValueError(f'Foreign ELF machine {machine} in {architecture} AppDir: {path}')
|
||||||
|
checked += 1
|
||||||
|
if not checked:
|
||||||
|
raise ValueError('No native ELF files found in AppDir')
|
||||||
|
return checked
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('directory', type=Path)
|
||||||
|
parser.add_argument('architecture', choices=('x86_64', 'aarch64'))
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(f'PASS: {check(args.directory, args.architecture)} native ELF files')
|
||||||
@@ -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,301 @@
|
|||||||
|
"""Disposable QEMU-only real-kernel USB/IP regression lab. Never use on a host."""
|
||||||
|
|
||||||
|
import contextlib, importlib.util, os, select, socket, subprocess, sys, tempfile, termios, time, tty
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts/devices"))
|
||||||
|
import usbip_manager as m
|
||||||
|
from usbip_protocol import Gate
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args):
|
||||||
|
result = subprocess.run(args, text=True, capture_output=True)
|
||||||
|
assert result.returncode == 0, (args, result.stdout, result.stderr)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def put(p, v):
|
||||||
|
Path(p).write_text(v)
|
||||||
|
|
||||||
|
|
||||||
|
def wait(test):
|
||||||
|
for _ in range(100):
|
||||||
|
value = test()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise AssertionError("condition timed out")
|
||||||
|
|
||||||
|
|
||||||
|
def gadget(name, serial, udc):
|
||||||
|
g = Path("/sys/kernel/config/usb_gadget") / name
|
||||||
|
g.mkdir()
|
||||||
|
for f, v in [("idVendor", "0x1d6b"), ("idProduct", "0x0104")]:
|
||||||
|
put(g / f, v)
|
||||||
|
(g / "strings/0x409").mkdir()
|
||||||
|
for f, v in [
|
||||||
|
("serialnumber", serial),
|
||||||
|
("manufacturer", "Regression lab"),
|
||||||
|
("product", name),
|
||||||
|
]:
|
||||||
|
put(g / "strings/0x409" / f, v)
|
||||||
|
(g / "configs/c.1").mkdir()
|
||||||
|
(g / "functions/acm.usb0").mkdir()
|
||||||
|
(g / "configs/c.1/acm.usb0").symlink_to(g / "functions/acm.usb0")
|
||||||
|
put(g / "UDC", udc)
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def find(serial):
|
||||||
|
return next((d for d in m.inventory() if d["serial"] == serial), None)
|
||||||
|
|
||||||
|
|
||||||
|
def chosen(serial, id):
|
||||||
|
return dict(wait(lambda: find(serial)), id=id)
|
||||||
|
|
||||||
|
|
||||||
|
def control(port, busid):
|
||||||
|
import struct
|
||||||
|
|
||||||
|
with socket.create_connection(("127.0.0.1", port)) as s:
|
||||||
|
s.sendall(
|
||||||
|
struct.pack("!HHI", 0x111, 0x8003, 0) + busid.encode().ljust(32, b"\0")
|
||||||
|
)
|
||||||
|
reply = s.recv(8)
|
||||||
|
assert len(reply) == 8
|
||||||
|
return struct.unpack("!HHI", reply)[2]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
assert sys.argv[1:] == [
|
||||||
|
"--disposable-vm"
|
||||||
|
], "Requires --disposable-vm on a fresh throwaway QEMU VM"
|
||||||
|
assert (
|
||||||
|
os.geteuid() == 0 and "QEMU" in Path("/sys/class/dmi/id/sys_vendor").read_text()
|
||||||
|
)
|
||||||
|
assert not Path("/sys/kernel/config/usb_gadget").exists() or not list(
|
||||||
|
Path("/sys/kernel/config/usb_gadget").iterdir()
|
||||||
|
), "Start with a fresh disposable VM"
|
||||||
|
|
||||||
|
run("modprobe", "dummy_hcd", "num=3")
|
||||||
|
run("modprobe", "libcomposite")
|
||||||
|
run("modprobe", "usbip-host")
|
||||||
|
run("modprobe", "vhci-hcd")
|
||||||
|
udcs = sorted(p.name for p in Path("/sys/class/udc").iterdir())
|
||||||
|
assert len(udcs) == 3
|
||||||
|
|
||||||
|
a_udc = udcs[0]
|
||||||
|
a = gadget("selected-a", "original-a", udcs[0])
|
||||||
|
b = gadget("selected-b", "original-b", udcs[1])
|
||||||
|
time.sleep(0.5)
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
m.CONFIG_DIR = root / "config"
|
||||||
|
m.SERVER_RUN_DIR = root / "run"
|
||||||
|
m.RUN_DIR = root / "client"
|
||||||
|
selected_a = chosen("original-a", "abcdef123456")
|
||||||
|
selected_b = chosen("original-b", "fedcba654321")
|
||||||
|
config = {"server": True, "exports": [selected_a, selected_b], "imports": []}
|
||||||
|
m.atomic_json(m.CONFIG_DIR / "config.json", config)
|
||||||
|
owned = {}
|
||||||
|
leases = {}
|
||||||
|
daemon = subprocess.Popen(
|
||||||
|
["usbipd", "-4"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
gate = None
|
||||||
|
proxy = None
|
||||||
|
try:
|
||||||
|
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
|
||||||
|
|
||||||
|
def claim(name):
|
||||||
|
for selected in config["exports"]:
|
||||||
|
id = selected["id"]
|
||||||
|
lease = leases.get(id)
|
||||||
|
record = owned.get(id)
|
||||||
|
if lease and lease.ready and record and record["busid"] == name:
|
||||||
|
return dict(record, id=id)
|
||||||
|
|
||||||
|
gate = Gate(root / "gate.sock", 0, claim)
|
||||||
|
proxy = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"socat",
|
||||||
|
"TCP-LISTEN:33240,bind=127.0.0.1,reuseaddr,fork",
|
||||||
|
"UNIX-CONNECT:" + str(root / "gate.sock"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
time.sleep(0.2)
|
||||||
|
# A native independent client remains attached throughout A's physical cycle.
|
||||||
|
print(
|
||||||
|
run(
|
||||||
|
"usbip",
|
||||||
|
"--tcp-port",
|
||||||
|
"33240",
|
||||||
|
"attach",
|
||||||
|
"-r",
|
||||||
|
"127.0.0.1",
|
||||||
|
"-b",
|
||||||
|
selected_b["busid"],
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def serial_transfer(gadget=b, serial="original-b"):
|
||||||
|
# Gadget B's function tty and remote vhci serial port, not local A's tty.
|
||||||
|
gadget_tty = (
|
||||||
|
"/dev/ttyGS"
|
||||||
|
+ (gadget / "functions/acm.usb0/port_num").read_text().strip()
|
||||||
|
)
|
||||||
|
client = wait(
|
||||||
|
lambda: next(
|
||||||
|
(
|
||||||
|
p
|
||||||
|
for p in Path("/sys/class/tty").glob("ttyACM*")
|
||||||
|
if "vhci_hcd" in str(p.resolve())
|
||||||
|
and any(
|
||||||
|
(q / "serial").exists()
|
||||||
|
and (q / "serial").read_text().strip() == serial
|
||||||
|
for q in p.resolve().parents
|
||||||
|
)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with contextlib.ExitStack() as stack:
|
||||||
|
fds = [
|
||||||
|
os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
for path in (gadget_tty, "/dev/" + client.name)
|
||||||
|
]
|
||||||
|
for fd in fds:
|
||||||
|
stack.callback(os.close, fd)
|
||||||
|
tty.setraw(fd)
|
||||||
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
||||||
|
payload = b"independent-b-still-transferring\n"
|
||||||
|
os.write(fds[0], payload)
|
||||||
|
assert select.select([fds[1]], [], [], 3)[0]
|
||||||
|
assert os.read(fds[1], len(payload)) == payload
|
||||||
|
|
||||||
|
serial_transfer()
|
||||||
|
for iteration in range(20):
|
||||||
|
put(a / "UDC", "\n")
|
||||||
|
time.sleep(0.03)
|
||||||
|
m.reconcile_exports(config, owned, leases)
|
||||||
|
assert selected_a["busid"] not in m.MATCH_BUSID.read_text().split()
|
||||||
|
put(a / "UDC", a_udc)
|
||||||
|
wait(lambda: find("original-a"))
|
||||||
|
time.sleep(0.25)
|
||||||
|
assert not (
|
||||||
|
errors := m.reconcile_exports(config, owned, leases)
|
||||||
|
), errors
|
||||||
|
assert leases[selected_a["id"]].ready
|
||||||
|
serial_transfer()
|
||||||
|
print(
|
||||||
|
"PASS: 20 unplug/replug cycles; stale slot removed and original exported; independent native serial transfer survives",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
put(a / "UDC", "\n")
|
||||||
|
time.sleep(0.05)
|
||||||
|
m.reconcile_exports(config, owned, leases)
|
||||||
|
a_udc = udcs[2]
|
||||||
|
put(a / "UDC", a_udc)
|
||||||
|
wait(lambda: find("original-a"))
|
||||||
|
time.sleep(0.25)
|
||||||
|
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
|
||||||
|
assert owned[selected_a["id"]]["busid"] != selected_a["busid"]
|
||||||
|
serial_transfer()
|
||||||
|
print(
|
||||||
|
"PASS: moving original to another USB port preserves independent serial transfer",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
# An unselected replacement returns to a local driver and cannot import.
|
||||||
|
put(a / "UDC", "\n")
|
||||||
|
time.sleep(0.05)
|
||||||
|
put(a / "strings/0x409/serialnumber", "unselected-replacement")
|
||||||
|
put(a / "UDC", a_udc)
|
||||||
|
replacement = wait(lambda: find("unselected-replacement"))
|
||||||
|
errors = m.reconcile_exports(config, owned, leases)
|
||||||
|
assert selected_a["id"] in errors
|
||||||
|
assert find("unselected-replacement")["driver"] == "usb"
|
||||||
|
assert control(33240, replacement["busid"]) != 0
|
||||||
|
# Also bind that unselected replacement in a separate native setup: the
|
||||||
|
# daemon can see it, but a direct import through our gate must still fail.
|
||||||
|
run("usbip", "bind", "-b", replacement["busid"])
|
||||||
|
assert replacement["busid"] in run("usbip", "list", "-r", "127.0.0.1")
|
||||||
|
assert control(33240, replacement["busid"]) != 0
|
||||||
|
m.reconcile_exports(config, owned, leases)
|
||||||
|
assert find("unselected-replacement")["driver"] == "usbip-host"
|
||||||
|
run("usbip", "unbind", "-b", replacement["busid"])
|
||||||
|
serial_transfer()
|
||||||
|
print(
|
||||||
|
"PASS: same-port replacement stays local, native import rejected, independent serial survives",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
# Same original then can be selected, imported and exchange native URBs.
|
||||||
|
put(a / "UDC", "\n")
|
||||||
|
time.sleep(0.1)
|
||||||
|
put(a / "strings/0x409/serialnumber", "original-a")
|
||||||
|
put(a / "UDC", a_udc)
|
||||||
|
wait(lambda: find("original-a"))
|
||||||
|
time.sleep(0.25)
|
||||||
|
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
|
||||||
|
print(
|
||||||
|
run(
|
||||||
|
"usbip",
|
||||||
|
"--tcp-port",
|
||||||
|
"33240",
|
||||||
|
"attach",
|
||||||
|
"-r",
|
||||||
|
"127.0.0.1",
|
||||||
|
"-b",
|
||||||
|
find("original-a")["busid"],
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
wait(lambda: len(m.imported_ports()) == 2)
|
||||||
|
serial_transfer(a, "original-a")
|
||||||
|
serial_transfer()
|
||||||
|
for port in m.imported_ports():
|
||||||
|
if port["busid"] == find("original-a")["busid"]:
|
||||||
|
run("usbip", "detach", "-p", str(port["port"]))
|
||||||
|
# Simulate losing process-owned descriptors before a present-device
|
||||||
|
# release. The manager must acquire and verify fresh usbfs pins.
|
||||||
|
leases.pop(selected_a["id"]).close()
|
||||||
|
config["exports"] = [selected_b]
|
||||||
|
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
|
||||||
|
assert find("original-a")["driver"] == "usb"
|
||||||
|
serial_transfer()
|
||||||
|
print(
|
||||||
|
"PASS: original reimport and present-device release restore local driver",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if proxy:
|
||||||
|
proxy.terminate()
|
||||||
|
proxy.wait()
|
||||||
|
if gate:
|
||||||
|
gate.terminate()
|
||||||
|
for port in m.imported_ports():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
run("usbip", "detach", "-p", str(port["port"]))
|
||||||
|
config["exports"] = []
|
||||||
|
print("cleanup", m.reconcile_exports(config, owned, leases), flush=True)
|
||||||
|
daemon.terminate()
|
||||||
|
daemon.wait()
|
||||||
|
for lease in leases.values():
|
||||||
|
lease.close()
|
||||||
|
print("PASS: no Oops/BUG in kernel log", flush=True)
|
||||||
|
for g in (a, b):
|
||||||
|
if (g / "UDC").read_text().strip():
|
||||||
|
put(g / "UDC", "\n")
|
||||||
|
(g / "configs/c.1/acm.usb0").unlink()
|
||||||
|
(g / "functions/acm.usb0").rmdir()
|
||||||
|
(g / "configs/c.1").rmdir()
|
||||||
|
(g / "strings/0x409").rmdir()
|
||||||
|
g.rmdir()
|
||||||
|
assert not any(
|
||||||
|
x in run("dmesg") for x in ["BUG:", "Oops:", "general protection fault"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Opt-in fresh-QEMU lab: actual generated units, restricted SSH and native USB URBs."""
|
||||||
|
|
||||||
|
import contextlib, os, select, shutil, sys, termios, time, tty
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts/devices"))
|
||||||
|
from check_usbip_kernel import m, run, put, gadget, find
|
||||||
|
|
||||||
|
|
||||||
|
def wait(test, seconds=30):
|
||||||
|
end = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < end:
|
||||||
|
value = test()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise AssertionError("timeout")
|
||||||
|
|
||||||
|
|
||||||
|
def transfer(g, serial):
|
||||||
|
client = wait(
|
||||||
|
lambda: next(
|
||||||
|
(
|
||||||
|
p
|
||||||
|
for p in Path("/sys/class/tty").glob("ttyACM*")
|
||||||
|
if "vhci_hcd" in str(p.resolve())
|
||||||
|
and any(
|
||||||
|
(q / "serial").exists()
|
||||||
|
and (q / "serial").read_text().strip() == serial
|
||||||
|
for q in p.resolve().parents
|
||||||
|
)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with contextlib.ExitStack() as stack:
|
||||||
|
fds = [
|
||||||
|
os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
||||||
|
for path in (
|
||||||
|
"/dev/ttyGS" + (g / "functions/acm.usb0/port_num").read_text().strip(),
|
||||||
|
"/dev/" + client.name,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for fd in fds:
|
||||||
|
stack.callback(os.close, fd)
|
||||||
|
tty.setraw(fd)
|
||||||
|
termios.tcflush(fd, termios.TCIOFLUSH)
|
||||||
|
for src, dst in ((fds[0], fds[1]), (fds[1], fds[0])):
|
||||||
|
payload = b"actual-systemd-ssh-usbip-serial\n"
|
||||||
|
os.write(src, payload)
|
||||||
|
assert select.select([dst], [], [], 5)[0]
|
||||||
|
assert os.read(dst, len(payload)) == payload
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
assert sys.argv[1:] == [
|
||||||
|
"--disposable-vm"
|
||||||
|
], "Requires --disposable-vm on a fresh throwaway QEMU VM"
|
||||||
|
assert not Path("/etc/proxmenux/usbip").exists()
|
||||||
|
assert "QEMU" in Path("/sys/class/dmi/id/sys_vendor").read_text()
|
||||||
|
for name in ("usbip_manager.py", "usbip_protocol.py", "usbip_lifetime.py"):
|
||||||
|
target = Path("/usr/local/share/proxmenux/scripts/devices") / name
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy(m.SCRIPT.parent / name, target)
|
||||||
|
run("modprobe", "dummy_hcd", "num=3")
|
||||||
|
run("modprobe", "libcomposite")
|
||||||
|
run("modprobe", "usbip-host")
|
||||||
|
run("modprobe", "vhci-hcd")
|
||||||
|
udcs = sorted(p.name for p in Path("/sys/class/udc").iterdir())
|
||||||
|
assert len(udcs) == 3
|
||||||
|
assert not list(Path("/sys/kernel/config/usb_gadget").iterdir())
|
||||||
|
a = gadget("service-a", "service-a", udcs[0])
|
||||||
|
b = gadget("service-b", "service-b", udcs[1])
|
||||||
|
time.sleep(0.5)
|
||||||
|
key = Path("/root/service-usbip-key")
|
||||||
|
run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key))
|
||||||
|
m.authorize_key(str(key) + ".pub")
|
||||||
|
public = Path("/etc/ssh/ssh_host_ed25519_key.pub").read_text().split()
|
||||||
|
Path("/root/.ssh/known_hosts").write_text(
|
||||||
|
"127.0.0.1 " + public[0] + " " + public[1] + "\n"
|
||||||
|
)
|
||||||
|
imports = []
|
||||||
|
try:
|
||||||
|
m.update_server(True)
|
||||||
|
sa = m.add_export(wait(lambda: find("service-a"))["busid"])
|
||||||
|
sb = m.add_export(wait(lambda: find("service-b"))["busid"])
|
||||||
|
wait(lambda: len([e for e in m.catalog() if e["status"] == "exported"]) == 2)
|
||||||
|
remote = m.validate_remote("127.0.0.1", 22, str(key))
|
||||||
|
for selected, port in ((sa, 33240), (sb, 33241)):
|
||||||
|
imports.append(m.add_import(remote, selected["id"], port))
|
||||||
|
wait(lambda: len(m.imported_ports()) == 2)
|
||||||
|
transfer(a, "service-a")
|
||||||
|
transfer(b, "service-b")
|
||||||
|
print(
|
||||||
|
"PASS: actual generated server/client systemd units and restricted SSH carry bidirectional native serial on two USBs",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
for cycle in range(3):
|
||||||
|
put(a / "UDC", "\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
transfer(b, "service-b")
|
||||||
|
put(a / "UDC", udcs[0])
|
||||||
|
wait(lambda: find("service-a"))
|
||||||
|
time.sleep(0.5)
|
||||||
|
# An old vhci port may still exist; wait for the new guest driver and verify data.
|
||||||
|
wait(
|
||||||
|
lambda: next(
|
||||||
|
(
|
||||||
|
e
|
||||||
|
for e in m.catalog()
|
||||||
|
if e["id"] == sa["id"] and e["status"] == "exported"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
time.sleep(6)
|
||||||
|
transfer(a, "service-a")
|
||||||
|
transfer(b, "service-b")
|
||||||
|
print(
|
||||||
|
"PASS: three physical reconnects automatically reattach through restricted SSH; second serial connection survives",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
m.remove_export(sa["id"])
|
||||||
|
assert find("service-a")["driver"] == "usb"
|
||||||
|
transfer(b, "service-b")
|
||||||
|
print(
|
||||||
|
"PASS: stop-sharing waits for real release and returns local driver while independent client keeps transferring",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
for item in imports:
|
||||||
|
m.remove_import(item["id"])
|
||||||
|
imports = []
|
||||||
|
m.update_server(False)
|
||||||
|
assert not m.read_json(m.state_path(), {})
|
||||||
|
assert find("service-b")["driver"] == "usb"
|
||||||
|
print(
|
||||||
|
"PASS: disable stops listeners, removes owned bindings, leaves both local USB drivers restored",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for item in imports:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
m.remove_import(item["id"])
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
m.update_server(False)
|
||||||
|
print(
|
||||||
|
run("journalctl", "-u", m.SERVER_UNIT, "--no-pager", "-n", "20"), flush=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Linux-only integration: real namespaces, socat and SSH; no USB hardware.
|
||||||
|
|
||||||
|
Run as root in the disposable CI runner. All keys, configuration, listeners and
|
||||||
|
child processes belong to a temporary fixture; no systemd units are installed.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import pwd
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[2] / "scripts/devices/usbip_manager.py"
|
||||||
|
|
||||||
|
|
||||||
|
def port():
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return sock.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def wait_until(test, processes, log, timeout=15):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
for process in processes:
|
||||||
|
if process.poll() is not None:
|
||||||
|
raise AssertionError(f"Fixture exited ({process.returncode}): {log.read_text()}")
|
||||||
|
try:
|
||||||
|
if test():
|
||||||
|
return
|
||||||
|
except (ConnectionError, FileNotFoundError, socket.timeout):
|
||||||
|
pass
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise AssertionError(f"Fixture did not become ready: {log.read_text()}")
|
||||||
|
|
||||||
|
|
||||||
|
def echo(sock):
|
||||||
|
# Exercise the production protocol gate through the real SSH stream. Empty
|
||||||
|
# fixture selection must produce an empty native DEVLIST, never raw echo.
|
||||||
|
import struct
|
||||||
|
sock.settimeout(10)
|
||||||
|
sock.sendall(struct.pack("!HHI", 0x0111, 0x8005, 0))
|
||||||
|
received = bytearray()
|
||||||
|
while len(received) < 12:
|
||||||
|
chunk = sock.recv(12 - len(received))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
received.extend(chunk)
|
||||||
|
assert bytes(received) == struct.pack("!HHII", 0x0111, 5, 0, 0), received
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
assert sys.platform == "linux" and os.geteuid() == 0, "Use the disposable Linux CI runner as root"
|
||||||
|
spec = importlib.util.spec_from_file_location("usbip_manager", SOURCE)
|
||||||
|
manager = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(manager)
|
||||||
|
# A mistaken direct launch must never create a listener in this namespace.
|
||||||
|
try:
|
||||||
|
manager.require_private_network()
|
||||||
|
except manager.USBIPError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected the host namespace to be rejected")
|
||||||
|
|
||||||
|
processes = []
|
||||||
|
tunnel = None
|
||||||
|
username = f"pmx-usbip-test-{os.getpid()}"
|
||||||
|
subprocess.run(["useradd", "--system", "--user-group", "--home-dir", "/nonexistent", "--shell", "/bin/sh", "--password", "*", username], check=True)
|
||||||
|
account = pwd.getpwnam(username)
|
||||||
|
manager.TRANSPORT_USER = username
|
||||||
|
# StrictModes checks every ancestor of AuthorizedKeysFile. A private
|
||||||
|
# subdirectory of /tmp still fails because /tmp itself is world writable.
|
||||||
|
# Keep the disposable fixture below root's home without changing its SSH
|
||||||
|
# configuration, account settings or existing authorized_keys.
|
||||||
|
with tempfile.TemporaryDirectory(prefix="pmx-usbip-", dir="/var/lib") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
os.chown(root, 0, account.pw_gid)
|
||||||
|
root.chmod(0o750)
|
||||||
|
fixture_source = root / "usbip_manager.py"
|
||||||
|
fixture_source.write_bytes(SOURCE.read_bytes())
|
||||||
|
fixture_source.chmod(0o644)
|
||||||
|
for name in ("usbip_protocol.py", "usbip_lifetime.py"):
|
||||||
|
(root / name).write_bytes((SOURCE.parent / name).read_bytes())
|
||||||
|
subprocess.run(["usermod", "--home", str(root), username], check=True)
|
||||||
|
log = root / "transport.log"
|
||||||
|
with log.open("w") as log_handle:
|
||||||
|
try:
|
||||||
|
fake_bin = root / "bin"
|
||||||
|
fake_bin.mkdir()
|
||||||
|
# The real daemon requires usbip-host and physical devices.
|
||||||
|
# Echo stands in only for its TCP endpoint, inside the namespace.
|
||||||
|
fake_daemon = fake_bin / "usbipd"
|
||||||
|
fake_daemon.write_text("""#!/usr/bin/python3
|
||||||
|
import socketserver
|
||||||
|
class Echo(socketserver.BaseRequestHandler):
|
||||||
|
def handle(self):
|
||||||
|
import struct
|
||||||
|
data = b''
|
||||||
|
while len(data) < 8:
|
||||||
|
block = self.request.recv(8 - len(data))
|
||||||
|
if not block:
|
||||||
|
return
|
||||||
|
data += block
|
||||||
|
if data == struct.pack('!HHI', 0x0111, 0x8005, 0):
|
||||||
|
self.request.sendall(struct.pack('!HHII', 0x0111, 5, 0, 0))
|
||||||
|
socketserver.ThreadingTCPServer.allow_reuse_address = True
|
||||||
|
socketserver.ThreadingTCPServer(('0.0.0.0', 3240), Echo).serve_forever()
|
||||||
|
""")
|
||||||
|
fake_daemon.chmod(0o755)
|
||||||
|
config = root / "config"
|
||||||
|
manager.atomic_json(config / "config.json", {"server": True, "exports": [], "imports": []})
|
||||||
|
server_run = root / "server"
|
||||||
|
runner = root / "server.py"
|
||||||
|
runner.write_text(f"""import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
spec = importlib.util.spec_from_file_location('usbip_manager', {str(fixture_source)!r})
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
m.CONFIG_DIR = Path({str(config)!r})
|
||||||
|
m.SERVER_RUN_DIR = Path({str(server_run)!r})
|
||||||
|
m.SYSFS = Path({str(root / 'no-usb-devices')!r})
|
||||||
|
m.TRANSPORT_USER = {username!r}
|
||||||
|
m.TRANSPORT_HOME = Path({str(root)!r})
|
||||||
|
subprocess.run(['ip', 'link', 'set', 'lo', 'up'], check=True)
|
||||||
|
m.serve()
|
||||||
|
""")
|
||||||
|
server = subprocess.Popen(["unshare", "--net", sys.executable, str(runner)],
|
||||||
|
env={**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}"},
|
||||||
|
stdout=log_handle, stderr=log_handle, start_new_session=True)
|
||||||
|
processes.append(server)
|
||||||
|
unix_path = server_run / "usbip.sock"
|
||||||
|
wait_until(lambda: unix_path.exists(), [server], log)
|
||||||
|
assert os.readlink(f"/proc/{server.pid}/ns/net") != os.readlink("/proc/self/ns/net")
|
||||||
|
assert stat.S_IMODE(server_run.stat().st_mode) == 0o750
|
||||||
|
assert stat.S_IMODE(unix_path.stat().st_mode) == 0o660
|
||||||
|
# Linux /proc/net reflects the caller's network namespace.
|
||||||
|
listening = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", ":3240"], text=True)
|
||||||
|
assert not listening.strip(), "Raw USB/IP TCP leaked into the host network namespace"
|
||||||
|
# Wait for both the proxy and its private TCP peer.
|
||||||
|
def unix_ready():
|
||||||
|
with socket.socket(socket.AF_UNIX) as connection:
|
||||||
|
connection.connect(str(unix_path))
|
||||||
|
echo(connection)
|
||||||
|
return True
|
||||||
|
wait_until(unix_ready, [server], log)
|
||||||
|
|
||||||
|
for name in ("host", "client", "wrong-client"):
|
||||||
|
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(root / name)], check=True)
|
||||||
|
ssh_dir = root / ".ssh"
|
||||||
|
ssh_dir.mkdir(mode=0o750)
|
||||||
|
os.chown(ssh_dir, 0, account.pw_gid)
|
||||||
|
authorized = ssh_dir / "authorized_keys"
|
||||||
|
gate = root / "gate.py"
|
||||||
|
gate.write_text(f"""import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
spec = importlib.util.spec_from_file_location('usbip_manager', {str(fixture_source)!r})
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
m.TRANSPORT_USER = {username!r}
|
||||||
|
m.TRANSPORT_HOME = Path({str(root)!r})
|
||||||
|
m.SERVER_RUN_DIR = Path({str(server_run)!r})
|
||||||
|
m.REMOTE_SOCKET = {str(unix_path)!r}
|
||||||
|
m.transport()
|
||||||
|
""")
|
||||||
|
gate.chmod(0o644)
|
||||||
|
authorized.write_text(f'restrict,command="{sys.executable} {gate}" ' + (root / "client.pub").read_text())
|
||||||
|
os.chown(authorized, 0, account.pw_gid)
|
||||||
|
authorized.chmod(0o640)
|
||||||
|
known = root / "known_hosts"
|
||||||
|
known.write_text("")
|
||||||
|
known.chmod(0o600)
|
||||||
|
ssh_port, local_port = port(), port()
|
||||||
|
ssh_config = root / "sshd_config"
|
||||||
|
ssh_config.write_text(f"""Port {ssh_port}
|
||||||
|
ListenAddress 127.0.0.1
|
||||||
|
HostKey {root / 'host'}
|
||||||
|
PidFile {root / 'sshd.pid'}
|
||||||
|
AuthorizedKeysFile {authorized}
|
||||||
|
PermitRootLogin no
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
UsePAM yes
|
||||||
|
StrictModes yes
|
||||||
|
AllowUsers {username}
|
||||||
|
AllowTcpForwarding local
|
||||||
|
AllowStreamLocalForwarding local
|
||||||
|
LogLevel VERBOSE
|
||||||
|
""")
|
||||||
|
Path("/run/sshd").mkdir(exist_ok=True, mode=0o755)
|
||||||
|
sshd = subprocess.Popen(["/usr/sbin/sshd", "-D", "-e", "-f", str(ssh_config)],
|
||||||
|
stdout=log_handle, stderr=log_handle, start_new_session=True)
|
||||||
|
processes.append(sshd)
|
||||||
|
def ssh_ready():
|
||||||
|
with socket.create_connection(("127.0.0.1", ssh_port), timeout=1):
|
||||||
|
return True
|
||||||
|
wait_until(ssh_ready, [server, sshd], log)
|
||||||
|
remote = manager.validate_remote("127.0.0.1", ssh_port, str(root / "client"))
|
||||||
|
extra = ["-o", f"UserKnownHostsFile={known}", "-o", "GlobalKnownHostsFile=/dev/null"]
|
||||||
|
def check_ssh(selected):
|
||||||
|
args = manager.ssh_args(selected)
|
||||||
|
args[1:1] = extra
|
||||||
|
return subprocess.run(args + ["--", selected["host"], "catalog"], capture_output=True, text=True, timeout=15)
|
||||||
|
unknown = check_ssh(remote)
|
||||||
|
assert unknown.returncode != 0 and "Host key verification failed" in unknown.stderr, unknown.stderr
|
||||||
|
host_public = (root / "host.pub").read_text().split()
|
||||||
|
known.write_text(f"[127.0.0.1]:{ssh_port} {host_public[0]} {host_public[1]}\n")
|
||||||
|
wrong = check_ssh(remote | {"key": str(root / "wrong-client")})
|
||||||
|
assert wrong.returncode != 0 and "Permission denied" in wrong.stderr, wrong.stderr
|
||||||
|
accepted = check_ssh(remote)
|
||||||
|
assert accepted.returncode == 0, accepted.stderr + "\n" + log.read_text()
|
||||||
|
forbidden = manager.ssh_args(remote)
|
||||||
|
forbidden[1:1] = extra
|
||||||
|
denied = subprocess.run(forbidden + ["--", remote["host"], "id"], capture_output=True, text=True, timeout=15)
|
||||||
|
assert denied.returncode != 0, "Forced command allowed a shell command"
|
||||||
|
forwarded = subprocess.run(forbidden + ["-W", "127.0.0.1:22", "--", remote["host"]], capture_output=True, text=True, timeout=15)
|
||||||
|
assert forwarded.returncode != 0 and "prohibited" in forwarded.stderr, forwarded.stderr
|
||||||
|
original = manager.ssh_args
|
||||||
|
def fixture_ssh(selected):
|
||||||
|
args = original(selected)
|
||||||
|
args[1:1] = extra
|
||||||
|
return args
|
||||||
|
manager.ssh_args = fixture_ssh
|
||||||
|
tunnel = manager.LoopbackTunnel(remote | {"local_port": local_port})
|
||||||
|
with socket.socket() as attacker:
|
||||||
|
try:
|
||||||
|
attacker.bind(("127.0.0.1", local_port))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("Another process stole the tunnel port")
|
||||||
|
def tunnel_ready():
|
||||||
|
with socket.create_connection(("127.0.0.1", local_port), timeout=1) as connection:
|
||||||
|
echo(connection)
|
||||||
|
return True
|
||||||
|
wait_until(tunnel_ready, processes, log)
|
||||||
|
listener = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", f":{local_port}"], text=True)
|
||||||
|
assert f"127.0.0.1:{local_port}" in listener and f"0.0.0.0:{local_port}" not in listener, listener
|
||||||
|
print("PASS: private namespace, dedicated-group Unix socket, no host USB/IP listener")
|
||||||
|
print("PASS: unknown/wrong keys, shell commands and arbitrary forwarding rejected; dedicated key accepted")
|
||||||
|
print("PASS: native USB/IP DEVLIST across real SSH -> Unix protocol gate -> private TCP")
|
||||||
|
finally:
|
||||||
|
if tunnel:
|
||||||
|
tunnel.terminate()
|
||||||
|
for process in reversed(processes):
|
||||||
|
if process.poll() is None:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
for process in reversed(processes):
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
process.wait(timeout=5)
|
||||||
|
subprocess.run(["pkill", "-KILL", "-u", str(account.pw_uid)], check=False)
|
||||||
|
# PAM/sshd children need to exit and be reaped before userdel.
|
||||||
|
for attempt in range(50):
|
||||||
|
cleanup = subprocess.run(["userdel", username], capture_output=True, text=True)
|
||||||
|
if cleanup.returncode == 0:
|
||||||
|
break
|
||||||
|
if cleanup.returncode != 8:
|
||||||
|
raise AssertionError(cleanup.stderr)
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
subprocess.run(["ps", "-fu", username], check=False)
|
||||||
|
raise AssertionError(cleanup.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(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())
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"""Build a verified ProxMenux LXC application-version tracking catalog.
|
"""Build a verified ProxMenux LXC application-version tracking catalog.
|
||||||
|
|
||||||
The source of truth is a pinned snapshot of community-scripts/ProxmoxVE,
|
The source of truth is a pinned snapshot of community-scripts/ProxmoxVE,
|
||||||
downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
|
plus community-scripts/core for its shared version helpers after the split.
|
||||||
|
Both are downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
|
||||||
|
|
||||||
An operational hint is emitted only when two independent pieces of the helper
|
An operational hint is emitted only when two independent pieces of the helper
|
||||||
scripts agree:
|
scripts agree:
|
||||||
@@ -37,6 +38,7 @@ from typing import Any, Iterable
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_REPOSITORY = "community-scripts/ProxmoxVE"
|
DEFAULT_REPOSITORY = "community-scripts/ProxmoxVE"
|
||||||
|
DEFAULT_CORE_REPOSITORY = "community-scripts/core"
|
||||||
DEFAULT_REF = "main"
|
DEFAULT_REF = "main"
|
||||||
API_VERSION = "2022-11-28"
|
API_VERSION = "2022-11-28"
|
||||||
USER_AGENT = "ProxMenux-app-tracking-catalog/1.0"
|
USER_AGENT = "ProxMenux-app-tracking-catalog/1.0"
|
||||||
@@ -169,6 +171,7 @@ def obtain_snapshot(
|
|||||||
repository: str,
|
repository: str,
|
||||||
ref: str,
|
ref: str,
|
||||||
cache_dir: Path,
|
cache_dir: Path,
|
||||||
|
required_paths: tuple[str, ...] = ("ct",),
|
||||||
) -> tuple[Path, str, str]:
|
) -> tuple[Path, str, str]:
|
||||||
commit = client.json(f"/repos/{repository}/commits/{ref}")
|
commit = client.json(f"/repos/{repository}/commits/{ref}")
|
||||||
commit_sha = str(commit.get("sha") or "")
|
commit_sha = str(commit.get("sha") or "")
|
||||||
@@ -177,7 +180,7 @@ def obtain_snapshot(
|
|||||||
|
|
||||||
snapshot_dir = cache_dir / repository.replace("/", "--") / commit_sha
|
snapshot_dir = cache_dir / repository.replace("/", "--") / commit_sha
|
||||||
marker = snapshot_dir / ".snapshot-complete"
|
marker = snapshot_dir / ".snapshot-complete"
|
||||||
if marker.exists() and (snapshot_dir / "ct").is_dir():
|
if marker.exists() and all((snapshot_dir / path).exists() for path in required_paths):
|
||||||
return snapshot_dir, commit_sha, marker.read_text(encoding="utf-8").strip()
|
return snapshot_dir, commit_sha, marker.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
payload = client.request(
|
payload = client.request(
|
||||||
@@ -190,6 +193,8 @@ def obtain_snapshot(
|
|||||||
temp_dir = Path(tempfile.mkdtemp(prefix=f"{commit_sha}.tmp-", dir=temp_parent))
|
temp_dir = Path(tempfile.mkdtemp(prefix=f"{commit_sha}.tmp-", dir=temp_parent))
|
||||||
try:
|
try:
|
||||||
safe_extract_tar(payload, temp_dir)
|
safe_extract_tar(payload, temp_dir)
|
||||||
|
if not all((temp_dir / path).exists() for path in required_paths):
|
||||||
|
raise CatalogError(f"Invalid source snapshot for {repository}@{commit_sha}")
|
||||||
(temp_dir / ".snapshot-complete").write_text(archive_sha + "\n", encoding="utf-8")
|
(temp_dir / ".snapshot-complete").write_text(archive_sha + "\n", encoding="utf-8")
|
||||||
if snapshot_dir.exists():
|
if snapshot_dir.exists():
|
||||||
shutil.rmtree(snapshot_dir)
|
shutil.rmtree(snapshot_dir)
|
||||||
@@ -521,23 +526,66 @@ def repo_from_helper(item: dict[str, Any]) -> str:
|
|||||||
return match.group(1) if match else ""
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def shared_helper_path(source: Path, core_source: Path | None = None) -> Path:
|
||||||
|
"""Locate source text only; never source or execute upstream shell code."""
|
||||||
|
candidates = [source / "misc" / "tools.func"]
|
||||||
|
if core_source is not None:
|
||||||
|
candidates.extend(core_source / path for path in ("lib/forge.func", "lib/tools.func"))
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
raise CatalogError(
|
||||||
|
"Shared version helpers not found. Older trees use misc/tools.func; "
|
||||||
|
"current trees require community-scripts/core (--core-source-dir for a local checkout)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def shell_function(text: str, name: str) -> str:
|
||||||
|
match = re.search(
|
||||||
|
rf"(?ms)^(?:function\s+)?{re.escape(name)}\s*\(\)\s*\{{[^\n]*\n(.*?)^\}}",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def verify_shared_helper(path: Path) -> None:
|
||||||
|
"""Check both ends of the cache contract, including the new forge wrappers."""
|
||||||
|
text = read_text(path)
|
||||||
|
read_body = shell_function(text, "check_for_gh_release")
|
||||||
|
write_body = shell_function(text, "fetch_and_deploy_gh_release")
|
||||||
|
if re.search(r'\bcheck_for_release\s+github\s+"\$1"\s+"\$2"', read_body):
|
||||||
|
read_body = shell_function(text, "check_for_release")
|
||||||
|
if re.search(r'\bfetch_and_deploy_release\s+github\s+"\$1"\s+"\$2"', write_body):
|
||||||
|
write_body = shell_function(text, "fetch_and_deploy_release")
|
||||||
|
normalization = r'app_lc="\$\(echo "\$\{app,,\}" \| tr -d [\'\"] [\'\"]\)"'
|
||||||
|
valid = (
|
||||||
|
re.search(normalization, read_body)
|
||||||
|
and re.search(normalization, write_body)
|
||||||
|
and re.search(r'(?:local\s+)?current_file="\$HOME/\.\$\{app_lc\}"', read_body)
|
||||||
|
and re.search(r'(?:local\s+)?version_file="\$HOME/\.\$\{app_lc\}"', write_body)
|
||||||
|
and re.search(r'echo "\$version" >"\$version_file"', write_body)
|
||||||
|
and re.search(r'\$current_file', read_body)
|
||||||
|
)
|
||||||
|
if not valid:
|
||||||
|
raise CatalogError(f"Could not verify the shared GitHub release cache contract in {path}")
|
||||||
|
|
||||||
|
|
||||||
def build_catalog(
|
def build_catalog(
|
||||||
source: Path,
|
source: Path,
|
||||||
helpers: dict[str, dict[str, Any]],
|
helpers: dict[str, dict[str, Any]],
|
||||||
|
*,
|
||||||
|
core_source: Path | None = None,
|
||||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||||
catalog: dict[str, Any] = {}
|
catalog: dict[str, Any] = {}
|
||||||
v2_apps: dict[str, Any] = {}
|
v2_apps: dict[str, Any] = {}
|
||||||
records: list[dict[str, Any]] = []
|
records: list[dict[str, Any]] = []
|
||||||
launchers = sorted((source / "ct").glob("*.sh"))
|
launchers = sorted((source / "ct").glob("*.sh"))
|
||||||
tools_func = source / "misc" / "tools.func"
|
tools_func = shared_helper_path(source, core_source)
|
||||||
tools_text = read_text(tools_func)
|
verify_shared_helper(tools_func)
|
||||||
shared_helper_verified = bool(
|
helper_contract = (
|
||||||
re.search(r'local version_file="\$HOME/\.\$\{app_lc\}"', tools_text)
|
"misc/tools.func" if tools_func == source / "misc" / "tools.func"
|
||||||
and re.search(r'echo "\$version" >"\$version_file"', tools_text)
|
else f"core/{tools_func.relative_to(core_source).as_posix()}"
|
||||||
and re.search(r'local current_file="\$HOME/\.\$\{app_lc\}"', tools_text)
|
|
||||||
)
|
)
|
||||||
if not shared_helper_verified:
|
|
||||||
raise CatalogError("Could not verify the shared GitHub release cache contract in misc/tools.func")
|
|
||||||
|
|
||||||
for launcher_path in launchers:
|
for launcher_path in launchers:
|
||||||
slug = launcher_path.stem
|
slug = launcher_path.stem
|
||||||
@@ -696,7 +744,7 @@ def build_catalog(
|
|||||||
"evidence": {
|
"evidence": {
|
||||||
"install": f"install/{installer_path.name}: matching deploy helper",
|
"install": f"install/{installer_path.name}: matching deploy helper",
|
||||||
"update": f"ct/{launcher_path.name}: matching release check",
|
"update": f"ct/{launcher_path.name}: matching release check",
|
||||||
"contract": "misc/tools.func: shared version-cache contract",
|
"contract": f"{helper_contract}: shared version-cache contract",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -712,7 +760,7 @@ def build_catalog(
|
|||||||
"evidence": {
|
"evidence": {
|
||||||
"install": f"install/{installer_path.name}: fetch_and_deploy_gh_release({release.app}, {release.repo})",
|
"install": f"install/{installer_path.name}: fetch_and_deploy_gh_release({release.app}, {release.repo})",
|
||||||
"update": f"ct/{launcher_path.name}: check_for_gh_release({release.app}, {release.repo})",
|
"update": f"ct/{launcher_path.name}: check_for_gh_release({release.app}, {release.repo})",
|
||||||
"contract": "misc/tools.func writes and reads /root/.<normalized-app>",
|
"contract": f"{helper_contract} writes and reads /root/.<normalized-app>",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -874,7 +922,7 @@ def build_catalog(
|
|||||||
"status_counts": status_counts,
|
"status_counts": status_counts,
|
||||||
"method_counts": method_counts,
|
"method_counts": method_counts,
|
||||||
"reason_counts": reason_counts,
|
"reason_counts": reason_counts,
|
||||||
"shared_release_cache_contract_verified": shared_helper_verified,
|
"shared_release_cache_contract_verified": True,
|
||||||
"helper_upstream_tag_validation": tag_validation,
|
"helper_upstream_tag_validation": tag_validation,
|
||||||
},
|
},
|
||||||
"records": records,
|
"records": records,
|
||||||
@@ -1307,6 +1355,12 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
|||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--repository", default=DEFAULT_REPOSITORY)
|
parser.add_argument("--repository", default=DEFAULT_REPOSITORY)
|
||||||
parser.add_argument("--ref", default=DEFAULT_REF)
|
parser.add_argument("--ref", default=DEFAULT_REF)
|
||||||
|
parser.add_argument("--core-repository", default=DEFAULT_CORE_REPOSITORY)
|
||||||
|
parser.add_argument("--core-ref", default=DEFAULT_REF)
|
||||||
|
parser.add_argument(
|
||||||
|
"--core-source-dir", type=Path,
|
||||||
|
help="Local community-scripts/core checkout for split source trees (no download)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--source-dir",
|
"--source-dir",
|
||||||
type=Path,
|
type=Path,
|
||||||
@@ -1346,6 +1400,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
client = GitHubClient()
|
client = GitHubClient()
|
||||||
commit_sha = "local-source"
|
commit_sha = "local-source"
|
||||||
archive_sha = "not-applicable"
|
archive_sha = "not-applicable"
|
||||||
|
core_provenance = None
|
||||||
try:
|
try:
|
||||||
if args.source_dir:
|
if args.source_dir:
|
||||||
source = args.source_dir.resolve()
|
source = args.source_dir.resolve()
|
||||||
@@ -1355,10 +1410,27 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
source, commit_sha, archive_sha = obtain_snapshot(
|
source, commit_sha, archive_sha = obtain_snapshot(
|
||||||
client, args.repository, args.ref, args.cache_dir.expanduser().resolve()
|
client, args.repository, args.ref, args.cache_dir.expanduser().resolve()
|
||||||
)
|
)
|
||||||
if not (source / "ct").is_dir() or not (source / "misc" / "tools.func").is_file():
|
if not (source / "ct").is_dir() or not (source / "install").is_dir():
|
||||||
raise CatalogError(f"Not a valid ProxmoxVE source tree: {source}")
|
raise CatalogError(f"Not a valid ProxmoxVE source tree: {source}")
|
||||||
|
core_source = None
|
||||||
|
if not (source / "misc" / "tools.func").is_file():
|
||||||
|
if args.core_source_dir:
|
||||||
|
core_source = args.core_source_dir.resolve()
|
||||||
|
core_commit, core_archive = "local-source", "not-applicable"
|
||||||
|
elif args.source_dir:
|
||||||
|
raise CatalogError("Split local source tree: provide --core-source-dir for its shared helpers")
|
||||||
|
else:
|
||||||
|
core_source, core_commit, core_archive = obtain_snapshot(
|
||||||
|
client, args.core_repository, args.core_ref,
|
||||||
|
args.cache_dir.expanduser().resolve(), required_paths=("lib/forge.func",),
|
||||||
|
)
|
||||||
|
core_provenance = {
|
||||||
|
"repository": args.core_repository, "ref": args.core_ref,
|
||||||
|
"commit_sha": core_commit, "archive_sha256": core_archive,
|
||||||
|
"source_dir": str(core_source),
|
||||||
|
}
|
||||||
helpers = load_helper_catalog(args.helpers_cache)
|
helpers = load_helper_catalog(args.helpers_cache)
|
||||||
catalog, audit, v2 = build_catalog(source, helpers)
|
catalog, audit, v2 = build_catalog(source, helpers, core_source=core_source)
|
||||||
audit["demoted_helper_markers"] = (
|
audit["demoted_helper_markers"] = (
|
||||||
[] if args.include_helper_markers else demote_generic_helper_markers(catalog, v2, audit)
|
[] if args.include_helper_markers else demote_generic_helper_markers(catalog, v2, audit)
|
||||||
)
|
)
|
||||||
@@ -1376,6 +1448,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"ref": args.ref,
|
"ref": args.ref,
|
||||||
"commit_sha": commit_sha,
|
"commit_sha": commit_sha,
|
||||||
"archive_sha256": archive_sha,
|
"archive_sha256": archive_sha,
|
||||||
|
"core": core_provenance,
|
||||||
"source_dir": str(source),
|
"source_dir": str(source),
|
||||||
"generated_at_unix": int(time.time()),
|
"generated_at_unix": int(time.time()),
|
||||||
"generator_sha256": sha256_file(Path(__file__).resolve()),
|
"generator_sha256": sha256_file(Path(__file__).resolve()),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ REVIEWERS = ROOT / ".github/oci-validation-reviewers"
|
|||||||
REPO = "https://github.com/MacRimi/ProxMenux"
|
REPO = "https://github.com/MacRimi/ProxMenux"
|
||||||
BOARD = "https://github.com/users/MacRimi/projects/1"
|
BOARD = "https://github.com/users/MacRimi/projects/1"
|
||||||
FORM = f"{REPO}/issues/new?template=oci-validation.yml"
|
FORM = f"{REPO}/issues/new?template=oci-validation.yml"
|
||||||
DISCUSSION = f"{REPO}/discussions/360"
|
DISCUSSION = f"{REPO}/discussions/440"
|
||||||
MAINTAINERS = {"macrimi"}
|
MAINTAINERS = {"macrimi"}
|
||||||
LAB = "laboratory-validated"
|
LAB = "laboratory-validated"
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ def render(verification, index):
|
|||||||
"only Docker provides, a fix still pending in ProxMenux — moves to Blocked on the board, with a line saying why "
|
"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.",
|
"and what would make it worth trying again.",
|
||||||
"",
|
"",
|
||||||
f"Questions and ideas about OCI testing go in [discussion #360]({DISCUSSION}).",
|
f"Questions and ideas about OCI testing go in [discussion #440]({DISCUSSION}).",
|
||||||
"",
|
"",
|
||||||
"## Tested by the community",
|
"## Tested by the community",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Run only the independently qualified, host-independent tests/ fixtures.
|
"""Run only the independently qualified, host-independent fixtures.
|
||||||
|
|
||||||
No filesystem discovery widens this manifest. Run from the repository root;
|
No filesystem discovery widens this manifest. Run from the repository root;
|
||||||
Python and Node have separate CI jobs so either failure blocks the check.
|
Python and Node have separate CI jobs so either failure blocks the check.
|
||||||
@@ -10,8 +10,28 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
PYTHON_FILES = (
|
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_presentation.py",
|
||||||
"tests/test_audit_safety_wording.py",
|
"tests/test_audit_safety_wording.py",
|
||||||
"tests/test_audit_policy.py",
|
"tests/test_audit_policy.py",
|
||||||
@@ -20,8 +40,27 @@ PYTHON_FILES = (
|
|||||||
"tests/storage/test_nvme_status_message.py",
|
"tests/storage/test_nvme_status_message.py",
|
||||||
"tests/test_fastfetch_config_generation.py",
|
"tests/test_fastfetch_config_generation.py",
|
||||||
"tests/test_notification_active_payloads.py",
|
"tests/test_notification_active_payloads.py",
|
||||||
|
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||||
)
|
)
|
||||||
NODE_FILES = (
|
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/lxc_updates/test_docker_delegated_ui.cjs",
|
||||||
"tests/test_audit_diagnostic_document.cjs",
|
"tests/test_audit_diagnostic_document.cjs",
|
||||||
"tests/test_audit_policy.cjs",
|
"tests/test_audit_policy.cjs",
|
||||||
@@ -33,6 +72,7 @@ NODE_FILES = (
|
|||||||
"tests/test_borg_ssh_guidance.cjs",
|
"tests/test_borg_ssh_guidance.cjs",
|
||||||
"tests/test_storage_messages.cjs",
|
"tests/test_storage_messages.cjs",
|
||||||
"tests/test_health_text.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.
|
# unittest's CLI accepts a missing pattern as a successful zero-test run.
|
||||||
@@ -69,6 +109,10 @@ def main() -> int:
|
|||||||
if args.lane == "python":
|
if args.lane == "python":
|
||||||
folder, filename = str(Path(path).parent), Path(path).name
|
folder, filename = str(Path(path).parent), Path(path).name
|
||||||
command = [sys.executable, "-I", "-B", "-c", PYTHON_RUN, folder, filename]
|
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:
|
else:
|
||||||
command = ["node", path]
|
command = ["node", path]
|
||||||
result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False)
|
result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Exercise the extracted launcher and its real native Python environment,
|
||||||
|
# intercepting only the final server exec so no host-monitoring server starts.
|
||||||
|
set -eu
|
||||||
|
appdir="$1"
|
||||||
|
export PMX_SMOKE_PYTHON
|
||||||
|
PMX_SMOKE_PYTHON="$(command -v python)"
|
||||||
|
fixture_bin="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$fixture_bin"' EXIT
|
||||||
|
cat > "$fixture_bin/python3" <<'WRAPPER'
|
||||||
|
#!/bin/sh
|
||||||
|
case "$1" in
|
||||||
|
*/flask_server.py)
|
||||||
|
exec "$PMX_SMOKE_PYTHON" -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print("PASS: extracted AppRun native runtime")'
|
||||||
|
;;
|
||||||
|
*) exec "$PMX_SMOKE_PYTHON" "$@" ;;
|
||||||
|
esac
|
||||||
|
WRAPPER
|
||||||
|
chmod +x "$fixture_bin/python3"
|
||||||
|
PATH="$fixture_bin:$PATH" bash "$appdir/AppRun"
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"Apagar una máquina virtual de forma segura",
|
"Apagar una máquina virtual de forma segura",
|
||||||
"Mostrar estado de VM",
|
"Mostrar estado de VM",
|
||||||
"Mostrar configuración de VM",
|
"Mostrar configuración de VM",
|
||||||
"Crear una instantánea de VM",
|
"Crear un snapshot de VM",
|
||||||
"Listar todos los contenedores LXC",
|
"Listar todos los contenedores LXC",
|
||||||
"Iniciar un contenedor LXC",
|
"Iniciar un contenedor LXC",
|
||||||
"Detener un contenedor LXC",
|
"Detener un contenedor LXC",
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"Dateieigentümer ändern",
|
"Dateieigentümer ändern",
|
||||||
"Tar.gz-Archiv extrahieren",
|
"Tar.gz-Archiv extrahieren",
|
||||||
"Tar.gz-Archiv erstellen",
|
"Tar.gz-Archiv erstellen",
|
||||||
"Festplattennutzung anzeigen",
|
"Laufwerksnutzung anzeigen",
|
||||||
"Verzeichnisgrößen anzeigen",
|
"Verzeichnisgrößen anzeigen",
|
||||||
"Speichernutzung anzeigen",
|
"Speichernutzung anzeigen",
|
||||||
"Laufende Prozesse anzeigen",
|
"Laufende Prozesse anzeigen",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
|
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
|
||||||
|
import ast
|
||||||
import datetime
|
import datetime
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -36,6 +37,10 @@ def restore_event(warnings):
|
|||||||
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
|
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."""
|
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
|
||||||
captured = []
|
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]'
|
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
|
||||||
def sink(title, body, severity, data):
|
def sink(title, body, severity, data):
|
||||||
markup = channel._format_html(title, body, severity, data)
|
markup = channel._format_html(title, body, severity, data)
|
||||||
@@ -48,12 +53,25 @@ def deliver(event_type, data, severity='INFO', language='en', manual=False, quie
|
|||||||
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
|
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
|
||||||
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
|
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
|
||||||
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
|
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
|
||||||
_record_history=lambda *a:None, _stats={'total_sent':0,'total_errors':0},
|
_record_history=record_history, _stats={'total_sent':0,'total_errors':0},
|
||||||
is_event_enabled=lambda event:True)
|
is_event_enabled=lambda event:True)
|
||||||
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
|
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',
|
resolve_notification_hostname=lambda host, config:host or 'node-a',
|
||||||
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
|
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
|
||||||
_should_bypass_ai=lambda event:True, _AI_BYPASS_EVENTS=frozenset({'backup_complete','backup_fail'}))
|
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'):
|
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
|
||||||
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
|
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
|
||||||
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
|
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
|
||||||
@@ -73,10 +91,11 @@ def deliver(event_type, data, severity='INFO', language='en', manual=False, quie
|
|||||||
if manual:
|
if manual:
|
||||||
assert not quiet
|
assert not quiet
|
||||||
result = manager.send_notification(event_type, severity, '', '', dict(data))
|
result = manager.send_notification(event_type, severity, '', '', dict(data))
|
||||||
assert result['success']
|
assert result['success'], result.get('errors')
|
||||||
else:
|
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')
|
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)
|
manager._dispatch_event(event)
|
||||||
|
assert not delivery_errors, '; '.join(delivery_errors)
|
||||||
if quiet:
|
if quiet:
|
||||||
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
|
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
|
assert len(rows)==1+len(quiet_before) and not captured
|
||||||
|
|||||||
@@ -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,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()
|
||||||
@@ -60,6 +60,124 @@ class WebDocsI18nTests(unittest.TestCase):
|
|||||||
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
||||||
self.assertEqual(MODULE.pending_leaves(source, target), [])
|
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):
|
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
||||||
source = {"title": "Updated installation guidance"}
|
source = {"title": "Updated installation guidance"}
|
||||||
target = {"title": "Vorherige Installationsanleitung"}
|
target = {"title": "Vorherige Installationsanleitung"}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -80,133 +80,74 @@ class CommandDescriptionsTests(unittest.TestCase):
|
|||||||
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
|
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
|
||||||
FIXTURE["locales"]["sv"][index])
|
FIXTURE["locales"]["sv"][index])
|
||||||
|
|
||||||
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
|
def assert_completed_catalogs_are_stable(self, *, future_messages=None):
|
||||||
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
|
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
|
||||||
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
|
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
|
||||||
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
|
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:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory).resolve()
|
||||||
for lang in ["en", *languages.split(",")]:
|
for path in shipped:
|
||||||
path = root / lang / "common.json"
|
destination = root / path.parent.name / "common.json"
|
||||||
path.parent.mkdir()
|
destination.parent.mkdir()
|
||||||
shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
|
shutil.copyfile(path, destination)
|
||||||
# This steady-state fixture assumes generation already finished.
|
if future_messages is not None:
|
||||||
# Seed only intentional message additions; keep the
|
source = json.loads((root / "en/common.json").read_text())
|
||||||
# recovered command arrays and the repo catalogs exact. Runtime
|
source["futureFeatureFixture"] = future_messages
|
||||||
# missing-key fallback is covered by the JSX seam tests and by
|
(root / "en/common.json").write_text(json.dumps(source))
|
||||||
# test_storage_messages.cjs and test_health_text.cjs.
|
|
||||||
if lang != "en":
|
|
||||||
temporary = json.loads(path.read_text())
|
|
||||||
for section, key in (
|
|
||||||
("archives", "emptyMessage"),
|
|
||||||
("destinations", "backupsKeptMessage"),
|
|
||||||
("destinations", "localAttachHelpMessage"),
|
|
||||||
):
|
|
||||||
temporary["backup"][section].setdefault(
|
|
||||||
key, catalog("en")["backup"][section][key])
|
|
||||||
temporary["storage"].setdefault(
|
|
||||||
"savedSmartData", catalog("en")["storage"]["savedSmartData"])
|
|
||||||
report = temporary["storage"]["smartReport"]
|
|
||||||
source_report = catalog("en")["storage"]["smartReport"]
|
|
||||||
for key in ("passedAssessment", "noReallocatedSectorsReported",
|
|
||||||
"passedMeaningTitle", "passedMeaning"):
|
|
||||||
report.setdefault(key, source_report[key])
|
|
||||||
for key in ("passedTitle", "passedText"):
|
|
||||||
report["recommendations"].setdefault(
|
|
||||||
key, source_report["recommendations"][key])
|
|
||||||
# New health-popup messages deliberately use runtime English
|
|
||||||
# fallback until each locale is translated. Model generator
|
|
||||||
# steady state only in this disposable copy, as for thermal keys.
|
|
||||||
for key in (
|
|
||||||
'certificateExpired',
|
|
||||||
'certificateExpiresDays',
|
|
||||||
'certificateInconclusive',
|
|
||||||
'cpuCheckFailed',
|
|
||||||
'cpuHighSustained',
|
|
||||||
'cpuSustained',
|
|
||||||
'fail2banCheckUnavailable',
|
|
||||||
'fail2banInactive',
|
|
||||||
'failedLoginsWithinThreshold',
|
|
||||||
'kernelPackagesMore',
|
|
||||||
'kernelPveUpdates',
|
|
||||||
'loginCheckUnavailable',
|
|
||||||
'memoryCheckFailed',
|
|
||||||
'memoryPressure',
|
|
||||||
'noFailedLogins',
|
|
||||||
'noSwapConfigured',
|
|
||||||
'noTemperatureSensor',
|
|
||||||
'nonRunningKernelUpdates',
|
|
||||||
'pendingPackageUpdates',
|
|
||||||
'pendingSecurityUpdates',
|
|
||||||
'pendingSecurityUpdatesDays',
|
|
||||||
'pendingSecurityUpdatesUnpatched',
|
|
||||||
'pveVersionAvailable',
|
|
||||||
'ramHighSustained',
|
|
||||||
'ramSustained',
|
|
||||||
'runningKernelUpdate',
|
|
||||||
'securityCheckUnavailable',
|
|
||||||
'sensorHighSamplesSpan',
|
|
||||||
'swapRamPressure',
|
|
||||||
'systemNotUpdatedMonths',
|
|
||||||
'systemNotUpdatedYear',
|
|
||||||
'temperatureElevated',
|
|
||||||
'unknownDetail',
|
|
||||||
'updatesAptError',
|
|
||||||
'updatesAptTimeout',
|
|
||||||
'updatesCheckUnavailable',
|
|
||||||
'uptimeKernelAdvice',
|
|
||||||
'uptimeUnavailable',
|
|
||||||
):
|
|
||||||
temporary["healthStatus"]["details"].setdefault(
|
|
||||||
key, catalog("en")["healthStatus"]["details"][key])
|
|
||||||
# New thermal messages also await automatic translation.
|
|
||||||
# Seed only these intentional additions in the temporary copy.
|
|
||||||
for key in (
|
|
||||||
"temperatureNoDisks", "temperatureNormal", "temperatureHighCount",
|
|
||||||
"temperatureCriticalCount", "temperatureUnavailable",
|
|
||||||
"temperatureIncomplete", "temperatureUnavailableCount",
|
|
||||||
):
|
|
||||||
temporary["storage"].setdefault(key, catalog("en")["storage"][key])
|
|
||||||
# Every shipped locale carries the temperature alert fallback
|
|
||||||
# keys; the seed only guards a catalog that lacks them. The
|
|
||||||
# runtime fallback is tested separately.
|
|
||||||
source_fallback = catalog("en")["runtime"]["notifications"]["fallback"]
|
|
||||||
fallback = temporary["runtime"]["notifications"]["fallback"]
|
|
||||||
for key in ("temperatureAlertTitle", "temperatureAlertBody",
|
|
||||||
"recordedReason", "recordedDetails"):
|
|
||||||
fallback.setdefault(key, source_fallback[key])
|
|
||||||
# Slovak remains the exact upstream catalog; model the
|
|
||||||
# pending outcome-key generator additions in disposable
|
|
||||||
# copies rather than modifying its curated values.
|
|
||||||
if lang == 'sk':
|
|
||||||
local = temporary['runtime']['notifications']
|
|
||||||
source = catalog('en')['runtime']['notifications']
|
|
||||||
for key, value in source['backup'].items():
|
|
||||||
local.setdefault('backup', {}).setdefault(key, value)
|
|
||||||
local['channels']['email']['status'].setdefault(
|
|
||||||
'unconfirmed', source['channels']['email']['status']['unconfirmed'])
|
|
||||||
local['channels']['email']['status'].setdefault(
|
|
||||||
'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings'])
|
|
||||||
path.write_text(json.dumps(temporary, ensure_ascii=False))
|
|
||||||
# Model steady state after the bot fills these intentional new
|
|
||||||
# messages; keep repository locales and all other leaves intact.
|
|
||||||
if lang != "en":
|
|
||||||
messages = json.loads(path.read_text())
|
|
||||||
english = catalog("en")
|
|
||||||
for section, key in (("encryption", "pbsHelp"),
|
|
||||||
("keyfileActions", "pveKeyDescription")):
|
|
||||||
messages["backup"][section].setdefault(key, english["backup"][section][key])
|
|
||||||
for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle",
|
|
||||||
"sshKeySetupHelp", "sshAuthorizedKeyHelp"):
|
|
||||||
messages["backup"]["destinations"].setdefault(
|
|
||||||
key, english["backup"]["destinations"][key])
|
|
||||||
messages["backup"]["actions"].setdefault(
|
|
||||||
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
|
|
||||||
path.write_text(json.dumps(messages, ensure_ascii=False))
|
|
||||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
|
||||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||||
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
||||||
output = io.StringIO()
|
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), \
|
with mock.patch.object(sys, "argv", argv), \
|
||||||
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
|
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, \
|
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
|
||||||
@@ -218,6 +159,17 @@ class CommandDescriptionsTests(unittest.TestCase):
|
|||||||
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||||
for lang in languages.split(","):
|
for lang in languages.split(","):
|
||||||
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
|
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):
|
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
|
||||||
node = require_node()
|
node = require_node()
|
||||||
|
|||||||
@@ -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,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,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,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,311 @@
|
|||||||
|
"""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 shlex
|
||||||
|
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 /")
|
||||||
|
self.config = self.root / "etc/log2ram.conf"
|
||||||
|
self.config.parent.mkdir()
|
||||||
|
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=true\nNOTIFICATION=true\n")
|
||||||
|
|
||||||
|
def run_setup(self, edit_failure=False):
|
||||||
|
# Extract only the shared setup function, never an administrative caller.
|
||||||
|
body = SETUP.read_text().split("pmx_log2ram_setup() {", 1)[1].split("\n}\n", 1)[0]
|
||||||
|
body = body.replace("/etc/", str(self.root / "etc") + "/")
|
||||||
|
for directory in ("cron.d", "tmpfiles.d"):
|
||||||
|
(self.root / "etc" / directory).mkdir(exist_ok=True)
|
||||||
|
stubs = r'''
|
||||||
|
translate() { printf '%s' "$1"; }
|
||||||
|
msg_info() { :; }
|
||||||
|
msg_ok() { :; }
|
||||||
|
pmx_install_pkg() { :; }
|
||||||
|
_pmx_log2ram_install_release() { :; }
|
||||||
|
_pmx_log2ram_pbs_logrotate() { :; }
|
||||||
|
_pmx_log2ram_remove_legacy() { :; }
|
||||||
|
pmx_edit_file() { local file="$1"; shift; sed -i "$@" "$file"; }
|
||||||
|
pmx_append_file() { cat >> "$1"; }
|
||||||
|
pmx_write_file() { cat > "$1"; }
|
||||||
|
pmx_remove_file() { rm -f "$1"; }
|
||||||
|
pmx_apply_setting() { :; }
|
||||||
|
systemctl() { printf '%s\n' "$*" >> "$SYSTEMCTL_TRACE"; }
|
||||||
|
'''
|
||||||
|
if edit_failure:
|
||||||
|
# A failed configuration writer must be handled even by root runners.
|
||||||
|
stubs += "\npmx_edit_file() { return 1; }\n"
|
||||||
|
script = (stubs + "\npmx_log2ram_setup() {" + body + "\n}\n"
|
||||||
|
+ "pmx_log2ram_setup 256 3 false\n")
|
||||||
|
env = dict(os.environ, SYSTEMCTL_TRACE=str(self.root / "systemctl.trace"),
|
||||||
|
PMX_L2R_TAG="1.7.2", PMX_L2R_SCRIPTS=str(KEEP.parent),
|
||||||
|
PMX_L2R_REVERT_UNIT=str(self.root / "etc/revert.service"))
|
||||||
|
return subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=10)
|
||||||
|
|
||||||
|
def test_setup_keeps_journal_aware_syncing_until_boot(self):
|
||||||
|
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=true\n")
|
||||||
|
result = self.run_setup()
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||||
|
self.assertIn("SIZE=256M\n", self.config.read_text())
|
||||||
|
self.assertFalse((self.log / "journal").is_symlink())
|
||||||
|
self.assertEqual((self.root / "systemctl.trace").read_text(), "daemon-reload\n")
|
||||||
|
|
||||||
|
def test_boot_disables_journal_aware_syncing_after_relocation(self):
|
||||||
|
self.assertEqual(self.run_setup().returncode, 0)
|
||||||
|
result = self.run_script()
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||||
|
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||||
|
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||||
|
self.assertIn("SIZE=256M\n", self.config.read_text())
|
||||||
|
# Reapplying the repair to an already-relocated installation is safe.
|
||||||
|
self.assertEqual(self.run_setup().returncode, 0)
|
||||||
|
self.assertEqual(self.run_script().returncode, 0)
|
||||||
|
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||||
|
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
|
||||||
|
|
||||||
|
def test_boot_migration_failure_is_not_ignored_by_startup(self):
|
||||||
|
self.assertEqual(self.run_setup().returncode, 0)
|
||||||
|
# A non-directory destination causes a real copy failure, without mounts.
|
||||||
|
conflict = self.root / "var/hdd.journal"
|
||||||
|
conflict.write_text("blocked")
|
||||||
|
unit = self.root / "etc/systemd/system/log2ram.service.d/proxmenux.conf"
|
||||||
|
command = next(line.split("=", 1)[1] for line in unit.read_text().splitlines()
|
||||||
|
if line.startswith("ExecStartPre="))
|
||||||
|
result = subprocess.run(["bash", *shlex.split(command.lstrip("-"))],
|
||||||
|
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)),
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||||
|
self.assertFalse(command.startswith("-"), "systemd must not ignore the helper's failure")
|
||||||
|
conflict.unlink()
|
||||||
|
self.assertEqual(self.run_script().returncode, 0)
|
||||||
|
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||||
|
|
||||||
|
def test_setup_adds_journal_aware_setting_when_missing(self):
|
||||||
|
self.config.write_text("SIZE=128M\n#JOURNALD_AWARE=false\n")
|
||||||
|
result = self.run_setup()
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("\nJOURNALD_AWARE=true\n", self.config.read_text())
|
||||||
|
self.assertEqual(self.run_script().returncode, 0)
|
||||||
|
self.assertIn("\nJOURNALD_AWARE=false\n", self.config.read_text())
|
||||||
|
|
||||||
|
def check_missing_setting_without_final_newline(self, content):
|
||||||
|
self.config.write_text(content)
|
||||||
|
result = self.run_setup()
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
for expected in ("true", "false"):
|
||||||
|
with self.subTest(journal_aware=expected):
|
||||||
|
# Read shell semantics in a fresh Bash, not a substring match.
|
||||||
|
sourced = subprocess.run(
|
||||||
|
["bash", "--noprofile", "--norc", "-c",
|
||||||
|
'unset JOURNALD_AWARE NOTIFICATION; source "$1"; '
|
||||||
|
'printf "%s\\n%s\\n" "${JOURNALD_AWARE-UNSET}" "${NOTIFICATION-UNSET}"',
|
||||||
|
"fixture", str(self.config)],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
self.assertEqual(sourced.returncode, 0, sourced.stderr)
|
||||||
|
self.assertEqual(sourced.stdout, expected + "\nfalse\n")
|
||||||
|
|
||||||
|
if expected == "true":
|
||||||
|
self.assertFalse((self.log / "journal").is_symlink())
|
||||||
|
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
|
||||||
|
result = self.run_script()
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||||
|
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||||
|
|
||||||
|
def test_missing_setting_after_comment_without_final_newline(self):
|
||||||
|
self.check_missing_setting_without_final_newline(
|
||||||
|
"SIZE=128M\nNOTIFICATION=false\n#JOURNALD_AWARE=false")
|
||||||
|
|
||||||
|
def test_missing_setting_after_assignment_without_final_newline(self):
|
||||||
|
self.check_missing_setting_without_final_newline("SIZE=128M\nNOTIFICATION=false")
|
||||||
|
|
||||||
|
def test_setup_reports_configuration_edit_failure(self):
|
||||||
|
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=false\n")
|
||||||
|
result = self.run_setup(edit_failure=True)
|
||||||
|
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||||
|
|
||||||
|
def test_an_existing_link_with_an_unusable_disk_target_fails(self):
|
||||||
|
self.assertEqual(self.run_script().returncode, 0)
|
||||||
|
self.config.write_text("JOURNALD_AWARE=true\n")
|
||||||
|
disk = self.root / "var/hdd.journal"
|
||||||
|
shutil.rmtree(disk)
|
||||||
|
disk.write_text("blocked")
|
||||||
|
result = self.run_script()
|
||||||
|
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=true\n")
|
||||||
|
|
||||||
|
def test_an_unexpected_journal_link_does_not_complete_migration(self):
|
||||||
|
shutil.rmtree(self.log / "journal")
|
||||||
|
(self.log / "journal").symlink_to("pveproxy")
|
||||||
|
result = self.run_script()
|
||||||
|
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(os.readlink(self.log / "journal"), "pveproxy")
|
||||||
|
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||||
|
|
||||||
|
def test_a_missing_journal_with_an_unusable_disk_target_fails(self):
|
||||||
|
shutil.rmtree(self.log / "journal")
|
||||||
|
(self.root / "var/hdd.journal").write_text("blocked")
|
||||||
|
result = self.run_script()
|
||||||
|
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||||
|
self.assertFalse((self.log / "journal").is_symlink())
|
||||||
|
|
||||||
|
def test_failed_source_removal_does_not_complete_migration(self):
|
||||||
|
(self.root / "var/hdd.journal").mkdir()
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", "-c", 'rm() { return 1; }; export -f rm; exec bash "$1"', "fixture", str(KEEP)],
|
||||||
|
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)), capture_output=True, text=True, timeout=10)
|
||||||
|
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||||
|
self.assertTrue((self.log / "journal/abc/system.journal").is_file())
|
||||||
|
|
||||||
|
def test_boot_configuration_failure_is_reported_and_can_be_retried(self):
|
||||||
|
self.config.unlink()
|
||||||
|
self.config.mkdir()
|
||||||
|
result = self.run_script()
|
||||||
|
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||||
|
self.config.rmdir()
|
||||||
|
self.config.write_text("JOURNALD_AWARE=true\n")
|
||||||
|
self.assertEqual(self.run_script().returncode, 0)
|
||||||
|
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=false\n")
|
||||||
|
|
||||||
|
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, timeout=10)
|
||||||
|
|
||||||
|
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.config.unlink() # Revert must not require or rewrite Log2RAM settings.
|
||||||
|
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, timeout=10)
|
||||||
|
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_both_modes_offer_a_repair_to_version_2_0_installs(self):
|
||||||
|
for filename, function, updater in (
|
||||||
|
("auto_post_install.sh", "install_log2ram_auto", "_update_existing_log2ram_auto"),
|
||||||
|
("customizable_post_install.sh", "configure_log2ram", "_update_existing_log2ram_custom"),
|
||||||
|
):
|
||||||
|
with self.subTest(mode=filename), tempfile.TemporaryDirectory() as folder:
|
||||||
|
config = Path(folder) / "log2ram.conf"
|
||||||
|
config.write_text("JOURNALD_AWARE=false\n")
|
||||||
|
source = (ROOT / "scripts/post_install" / filename).read_text()
|
||||||
|
body = source.split(function + "() {", 1)[1].split("\n}\n", 1)[0]
|
||||||
|
body = body.replace("/etc/log2ram.conf", str(config))
|
||||||
|
script = ("pmx_journal_context() { :; }\n"
|
||||||
|
+ updater + "() { printf '%s\\n' \"$1\"; }\n"
|
||||||
|
+ function + "() {" + body + "\n}\n" + function)
|
||||||
|
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout.strip(), "2.1")
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -110,7 +110,7 @@ class LynisThresholdContracts(unittest.TestCase):
|
|||||||
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
|
_, 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.
|
# 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,
|
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
|
||||||
MEMORY_WARNING=80, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
|
MEMORY_WARNING=80, MEMORY_CRITICAL=90, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
|
||||||
memory = SimpleNamespace(percent=50, available=10, total=100)
|
memory = SimpleNamespace(percent=50, available=10, total=100)
|
||||||
swap = SimpleNamespace(percent=90, used=90, 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),
|
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ lives: the scripts it launches are the ones the menus launch."""
|
|||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import runpy
|
import runpy
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
@@ -42,11 +46,13 @@ class IndexTests(unittest.TestCase):
|
|||||||
self.assertEqual(unknown, [])
|
self.assertEqual(unknown, [])
|
||||||
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
|
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
|
||||||
|
|
||||||
def test_every_script_a_menu_launches_can_be_found(self):
|
def test_every_script_a_pve_menu_launches_can_be_found(self):
|
||||||
indexed = {item["script"] for item in self.index}
|
indexed = {item["script"] for item in self.index}
|
||||||
launched = set()
|
launched = set()
|
||||||
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
|
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
|
||||||
if menu.name in ("main_menu.sh", "lxc_menu.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
|
continue
|
||||||
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
|
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
|
||||||
launched -= {"menus/main_menu.sh"}
|
launched -= {"menus/main_menu.sh"}
|
||||||
@@ -54,6 +60,27 @@ class IndexTests(unittest.TestCase):
|
|||||||
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
|
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
|
||||||
self.assertEqual(sorted(launched - indexed), [])
|
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):
|
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
|
||||||
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||||
self.assertIn('search "$(translate "Search options and applications")"', menu)
|
self.assertIn('search "$(translate "Search options and applications")"', menu)
|
||||||
|
|||||||
@@ -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,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,50 @@
|
|||||||
|
"""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 = ''.join(re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', text, re.M | re.S).group(0)
|
||||||
|
for name in ('pve_manager_version', 'pve_supports_oci'))
|
||||||
|
cls.text = text
|
||||||
|
|
||||||
|
def supported(self, version):
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
fake = Path(folder, 'dpkg-query')
|
||||||
|
# Without Proxmox the package is not installed: dpkg-query fails.
|
||||||
|
fake.write_text('#!/bin/sh\nexit 1\n' if version is None else
|
||||||
|
f'#!/bin/sh\nprintf %s "{version}"\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_the_version_is_read_without_the_slow_pveversion(self):
|
||||||
|
self.assertNotIn('pveversion', self.function)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import runpy
|
import runpy
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from types import ModuleType, SimpleNamespace
|
from types import ModuleType, SimpleNamespace
|
||||||
@@ -28,6 +29,10 @@ TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
|
|||||||
|
|
||||||
|
|
||||||
def extracted(path, name, scope):
|
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
|
node = next(n for n in ast.parse(path.read_text()).body
|
||||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||||
@@ -52,7 +57,8 @@ class InventoryMessages(unittest.TestCase):
|
|||||||
state = ModuleType('oci_installation_state')
|
state = ModuleType('oci_installation_state')
|
||||||
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
|
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 '',
|
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
|
||||||
'translate': translate, 're': re}
|
'translate': translate, 're': re, 'subprocess': subprocess,
|
||||||
|
'_selected_configuration': remover.guest_config}
|
||||||
summary = extracted(MENU, '_removal_summary', scope)
|
summary = extracted(MENU, '_removal_summary', scope)
|
||||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||||
'oci_installation_state': state}):
|
'oci_installation_state': state}):
|
||||||
@@ -69,6 +75,9 @@ class InventoryMessages(unittest.TestCase):
|
|||||||
self.assertNotIn('Host directories that are kept, with their content:', text)
|
self.assertNotIn('Host directories that are kept, with their content:', text)
|
||||||
|
|
||||||
def test_removal_notice_qualifies_saved_record_source(self):
|
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 = []
|
events = []
|
||||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||||
@@ -79,7 +88,7 @@ class InventoryMessages(unittest.TestCase):
|
|||||||
'remove_owned_host_firewall': lambda primary: True,
|
'remove_owned_host_firewall': lambda primary: True,
|
||||||
'run': lambda *args: events.append(('run', args)),
|
'run': lambda *args: events.append(('run', args)),
|
||||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||||
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
'Path': inert_path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
|
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
|
||||||
@@ -114,7 +123,7 @@ class RecoveryMessages(unittest.TestCase):
|
|||||||
instances.ROOT = Path('/inert')
|
instances.ROOT = Path('/inert')
|
||||||
instances.read = lambda root, vmid: self.primary
|
instances.read = lambda root, vmid: self.primary
|
||||||
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
|
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
|
||||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
|
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
|
||||||
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
|
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||||
'_remove': lambda *args: self.fail('remove must not run'),
|
'_remove': lambda *args: self.fail('remove must not run'),
|
||||||
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
|
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -20,7 +20,7 @@ INSTALL = ROOT / 'oci/remote/install_oci.sh'
|
|||||||
PREVIEW = 'Private network targeted for release if no other guest uses it:'
|
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.'
|
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.'
|
RESULT = 'Removal command finished; review any warnings above.'
|
||||||
BRIDGE = 'Private network release attempted:'
|
BRIDGE = 'Private network of the application released:'
|
||||||
BRIDGE_FAILURE = 'Could not complete private network release:'
|
BRIDGE_FAILURE = 'Could not complete private network release:'
|
||||||
SUCCESS = 'The application was removed'
|
SUCCESS = 'The application was removed'
|
||||||
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
|
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
|
||||||
@@ -35,6 +35,10 @@ KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, POR
|
|||||||
|
|
||||||
|
|
||||||
def extract(path, name, scope):
|
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)
|
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)
|
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||||
return scope[name]
|
return scope[name]
|
||||||
@@ -56,7 +60,8 @@ class RemovalWordings(unittest.TestCase):
|
|||||||
state = ModuleType('oci_installation_state')
|
state = ModuleType('oci_installation_state')
|
||||||
state.parse_config = lambda raw: {}
|
state.parse_config = lambda raw: {}
|
||||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
||||||
'translate': translated or (lambda value: value), 're': re}
|
'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,
|
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||||
'oci_installation_state': state}):
|
'oci_installation_state': state}):
|
||||||
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
|
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
|
||||||
@@ -87,7 +92,8 @@ class RemovalWordings(unittest.TestCase):
|
|||||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||||
'oci_installation_state': state}):
|
'oci_installation_state': state}):
|
||||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
||||||
'translate': lambda value: value, 're': re}
|
'translate': lambda value: value, 're': re,
|
||||||
|
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
|
||||||
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
|
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
|
||||||
self.assertIn(WHOLE, preview)
|
self.assertIn(WHOLE, preview)
|
||||||
|
|
||||||
@@ -255,7 +261,7 @@ class RemovalWordings(unittest.TestCase):
|
|||||||
self.assertIn(('warn', FIREWALL), events)
|
self.assertIn(('warn', FIREWALL), events)
|
||||||
self.assertEqual(events[-1], ('ok', RESULT))
|
self.assertEqual(events[-1], ('ok', RESULT))
|
||||||
|
|
||||||
def test_ignored_bridge_command_failures_do_not_claim_release(self):
|
def test_a_failed_bridge_release_warns_instead_of_claiming_it(self):
|
||||||
events = []
|
events = []
|
||||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||||
@@ -272,11 +278,12 @@ class RemovalWordings(unittest.TestCase):
|
|||||||
'oci_console': SimpleNamespace(remove_log=lambda 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,
|
'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,
|
'translate': lambda text: text, 'msg_info': lambda text: None,
|
||||||
'msg_ok': lambda text: events.append(('ok', text)), 'msg_warn': 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)
|
extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
|
||||||
self.assertIn(('attempt', 'vmbr9'), events)
|
self.assertIn(('attempt', 'vmbr9'), events)
|
||||||
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
|
self.assertIn(('warn', BRIDGE_FAILURE + ' vmbr9'), events)
|
||||||
self.assertNotIn(('ok', 'Private network of the application released: vmbr9'), events)
|
self.assertNotIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||||
|
|
||||||
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
|
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
|
||||||
events = []
|
events = []
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
import subprocess
|
import subprocess
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||||
@@ -42,12 +42,56 @@ class SelectionSetupWording(TestCase):
|
|||||||
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
|
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
|
||||||
_clean_orphans=lambda project: None,
|
_clean_orphans=lambda project: None,
|
||||||
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
|
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
|
||||||
carry_records=lambda project, **options: {},
|
carry_records=lambda project, **options: {}, _records_unread=lambda project: False,
|
||||||
translate=lambda s: s)
|
translate=lambda s: s)
|
||||||
fn(Path('/fixture'), ui)
|
fn(Path('/fixture'), ui)
|
||||||
ui.message.assert_called_once_with(EMPTY, 'OCI management')
|
ui.message.assert_called_once_with(EMPTY, 'OCI management')
|
||||||
ui.choose.assert_not_called()
|
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):
|
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
|
||||||
class Entry:
|
class Entry:
|
||||||
name = '101'
|
name = '101'
|
||||||
@@ -89,7 +133,7 @@ class SelectionSetupWording(TestCase):
|
|||||||
with self.subTest(app=app):
|
with self.subTest(app=app):
|
||||||
path = ROOT / f'oci/remote/install_{app}_stack.sh'
|
path = ROOT / f'oci/remote/install_{app}_stack.sh'
|
||||||
lines = path.read_text().splitlines()
|
lines = path.read_text().splitlines()
|
||||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('curl -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
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)])
|
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
|
||||||
# Actual Bash failure expression, never source the parent installer.
|
# Actual Bash failure expression, never source the parent installer.
|
||||||
harness = '''set -u
|
harness = '''set -u
|
||||||
@@ -97,6 +141,7 @@ APPLICATION_LAN_IP=192.0.2.1
|
|||||||
APPLICATION_ID=101
|
APPLICATION_ID=101
|
||||||
OCI_LOG=/dev/null
|
OCI_LOG=/dev/null
|
||||||
curl() { return 7; }
|
curl() { return 7; }
|
||||||
|
oci_curl() { shift; curl "$@"; }
|
||||||
translate() { printf '%s' "$1"; }
|
translate() { printf '%s' "$1"; }
|
||||||
print_first_boot_diagnostics() { :; }
|
print_first_boot_diagnostics() { :; }
|
||||||
die() { printf 'ERROR:%s\\n' "$1"; }
|
die() { printf 'ERROR:%s\\n' "$1"; }
|
||||||
|
|||||||
@@ -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,218 @@
|
|||||||
|
"""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 catalog_refresh, 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(cli,'_catalog_options',return_value=([],{}, {}, {}, [])), \
|
||||||
|
patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
|
||||||
|
patch.object(updates,'UpdateCheck') as checker, \
|
||||||
|
patch.object(catalog_refresh,'CatalogRefresh') as catalog_checker:
|
||||||
|
# Keep this wording/error-handler test offline and free of writes.
|
||||||
|
# The real background worker has its own catalog lifecycle tests.
|
||||||
|
catalog_check=catalog_checker.return_value.__enter__.return_value
|
||||||
|
catalog_check.active=False;catalog_check.consume.return_value=False
|
||||||
|
catalog_check.failed=False;catalog_check.label.return_value='Refresh catalog'
|
||||||
|
check=checker.return_value.__enter__.return_value
|
||||||
|
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
|
||||||
|
check.label.return_value='Check for updates'
|
||||||
|
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()
|
||||||
@@ -3,6 +3,7 @@ import ast
|
|||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import runpy
|
import runpy
|
||||||
@@ -23,9 +24,12 @@ OLD_GUARD = 'The current template changes the image or identity; an explicit mig
|
|||||||
GUARD = 'The current template changes the template identity or image repository; 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 '
|
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.')
|
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
|
||||||
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with '
|
PREVIEW = ('If a newer application image is available, the container is stopped and backed up, and the backup '
|
||||||
'the new image to update the container. If the update fails after the container was changed, the backup '
|
'integrity is verified. The container is then updated using the new image.\n\n'
|
||||||
'is restored. An update that stops halfway is recovered from the OCI management menu.')
|
'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):
|
def extracted(path, name, scope):
|
||||||
@@ -129,7 +133,9 @@ class UpdateWording(unittest.TestCase):
|
|||||||
reconcile = ModuleType('oci_instance_reconcile')
|
reconcile = ModuleType('oci_instance_reconcile')
|
||||||
reconcile.propose = lambda *args: None
|
reconcile.propose = lambda *args: None
|
||||||
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
|
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
|
||||||
|
'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
|
||||||
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
'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),
|
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||||
'carry_records': lambda project, **options: {},
|
'carry_records': lambda project, **options: {},
|
||||||
'_work_backup_ready': lambda project, ui, vmid: True,
|
'_work_backup_ready': lambda project, ui, vmid: True,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
|
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
|
||||||
import ast
|
import ast
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
from unittest.mock import Mock
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||||
@@ -65,9 +69,45 @@ class OciWatchdogInstaller(TestCase):
|
|||||||
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
|
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
|
||||||
self.assertLess(asked, install.index('approved = wizard.review('))
|
self.assertLess(asked, install.index('approved = wizard.review('))
|
||||||
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
|
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
|
||||||
self.assertIn('set_watchdog(PROJECT_ROOT, sorted(vmids), True)', install)
|
self._assert_jobs_are_excluded_from_installed_watchdog()
|
||||||
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
|
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):
|
def test_both_management_menus_offer_it(self):
|
||||||
self.assertEqual(self.MENU.count("('watchdog', "), 2)
|
self.assertEqual(self.MENU.count("('watchdog', "), 2)
|
||||||
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
|
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
|||||||
CHOICE = 'Recover the previous installation'
|
CHOICE = 'Recover the previous installation'
|
||||||
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
|
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.'
|
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 = (
|
NEW_KEYS = (
|
||||||
'The Compose file describes several services:',
|
'Containers of the application:',
|
||||||
'Only one service at a time can be installed this way.',
|
|
||||||
'No docker run command was given',
|
'No docker run command was given',
|
||||||
'Image selection was cancelled',
|
'Image selection was cancelled',
|
||||||
'Could not inspect the image in its registry:',
|
'Could not inspect the image in its registry:',
|
||||||
'Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.',
|
REGISTRY_ADVICE,
|
||||||
BEFORE, AFTER,
|
BEFORE, AFTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ def function(path, name, **dependencies):
|
|||||||
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
|
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
|
||||||
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
|
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
|
||||||
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
|
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
|
||||||
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024)
|
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)
|
scope.update(dependencies)
|
||||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
|
||||||
str(path), 'exec'), scope)
|
str(path), 'exec'), scope)
|
||||||
@@ -74,15 +76,19 @@ class OciWordingTests(TestCase):
|
|||||||
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
|
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
|
||||||
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
|
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
|
||||||
|
|
||||||
def test_multiple_services_named_not_images(self):
|
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',
|
template = function(CUSTOM, 'template_from_compose',
|
||||||
_services=lambda doc: doc['services'], _check_mounts=lambda _: None)
|
_services=lambda doc: doc['services'], _template_from_stack=stack)
|
||||||
with self.assertRaises(ConversionError) as caught:
|
self.assertIs(template(json.dumps(document)), converted)
|
||||||
template(json.dumps({'services': {'alpha': {'image': 'same:latest'},
|
stack.assert_called_once()
|
||||||
'beta': {'image': 'same:latest'}}}))
|
self.assertEqual(stack.call_args.args, (document, None))
|
||||||
self.assertEqual(str(caught.exception),
|
self.assertFalse(stack.call_args.kwargs['interpolated'])
|
||||||
'The Compose file describes several services: alpha, beta. '
|
self.assertIsNone(stack.call_args.kwargs.get('image_entrypoints'))
|
||||||
'Only one service at a time can be installed this way.')
|
|
||||||
|
|
||||||
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
|
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
|
||||||
for title, expected in ((None, 'No Compose file was given'),
|
for title, expected in ((None, 'No Compose file was given'),
|
||||||
@@ -105,7 +111,8 @@ class OciWordingTests(TestCase):
|
|||||||
def test_registry_nonzero_and_success_command_unchanged(self):
|
def test_registry_nonzero_and_success_command_unchanged(self):
|
||||||
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
|
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
|
||||||
run = Mock(return_value=result)
|
run = Mock(return_value=result)
|
||||||
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run))
|
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run),
|
||||||
|
_registry_lookup=lambda reference: reference)
|
||||||
self.assertEqual(report('example:latest'),
|
self.assertEqual(report('example:latest'),
|
||||||
(False, 'Could not inspect the image in its registry: example:latest'))
|
(False, 'Could not inspect the image in its registry: example:latest'))
|
||||||
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
|
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
|
||||||
@@ -116,16 +123,16 @@ class OciWordingTests(TestCase):
|
|||||||
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
|
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
|
||||||
self.assertEqual(run.call_count, 1)
|
self.assertEqual(run.call_count, 1)
|
||||||
|
|
||||||
def test_failed_registry_inspection_has_neutral_advice(self):
|
def test_failed_registry_inspection_points_to_registry_accounts(self):
|
||||||
ui = SimpleNamespace(message=Mock())
|
ui = SimpleNamespace(message=Mock())
|
||||||
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
|
fn = function(CUSTOM, '_explore', read_definition=lambda _: ('definition', 'source'),
|
||||||
ignored_settings=lambda _: [], template_from_compose=lambda _: {
|
ignored_settings=lambda _: [], template_from_compose=lambda _: {
|
||||||
'container_contract': {'image': {'reference': 'example:latest'}}},
|
'container_contract': {'image': {'reference': 'example:latest'}}},
|
||||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'))
|
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
|
||||||
|
_registry_refused=lambda _: False)
|
||||||
fn(ui)
|
fn(ui)
|
||||||
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n'
|
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n' + REGISTRY_ADVICE)
|
||||||
'Check the image reference and registry access. If the registry is unavailable, '
|
self.assertNotIn('not supported yet', ui.message.call_args.args[0])
|
||||||
'try again later. A private registry needs credentials, which are not supported yet.')
|
|
||||||
|
|
||||||
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
|
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
|
||||||
# Pin the real translated management choice, not an English AST constant alone.
|
# Pin the real translated management choice, not an English AST constant alone.
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -20,7 +20,11 @@ class OutcomePromises(TestCase):
|
|||||||
texts = oci_menu_texts()
|
texts = oci_menu_texts()
|
||||||
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
|
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]
|
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
|
||||||
self.assertEqual(len(previews), 3)
|
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):
|
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)
|
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
|
||||||
|
|||||||
@@ -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,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,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,488 @@
|
|||||||
|
"""USB/IP ownership, device identity, SSH policy and service regression tests.
|
||||||
|
|
||||||
|
No physical USB devices, live systemd units or host configuration are changed.
|
||||||
|
The optional Linux integration lane exercises actual SSH, socat and namespaces.
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
SOURCE = ROOT / "scripts/devices/usbip_manager.py"
|
||||||
|
EXPORT = "abcdef123456"
|
||||||
|
CLIENT = "fedcba654321"
|
||||||
|
|
||||||
|
|
||||||
|
def load():
|
||||||
|
spec = importlib.util.spec_from_file_location("usbip_manager", SOURCE)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class USBIPTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.m = load()
|
||||||
|
for attr, path in (("CONFIG_DIR", "config"), ("RUN_DIR", "client-run"),
|
||||||
|
("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs"),
|
||||||
|
("NET_SYSFS", "net")):
|
||||||
|
setattr(self.m, attr, self.root / path)
|
||||||
|
getattr(self.m, attr).mkdir()
|
||||||
|
class MatchTable:
|
||||||
|
# Mirror the kernel contract, not regular-file write semantics:
|
||||||
|
# del(ALLOC)=REMOV, del(ADDED/REMOV) retains the named slot.
|
||||||
|
def __init__(self):
|
||||||
|
self.slots = {}
|
||||||
|
def write_text(self, value):
|
||||||
|
action, busid = value.split()
|
||||||
|
if self.slots.get(busid) == "ALLOC":
|
||||||
|
self.slots[busid] = "REMOV"
|
||||||
|
def read_text(self):
|
||||||
|
return " ".join(self.slots)
|
||||||
|
self.m.MATCH_BUSID = MatchTable()
|
||||||
|
self.account = SimpleNamespace(pw_uid=1234, pw_gid=1234)
|
||||||
|
self.calls = []
|
||||||
|
self.key = self.root / "id_ed25519"
|
||||||
|
self.key.write_text("test-key")
|
||||||
|
self.key.chmod(0o600)
|
||||||
|
self.remote = {"host": "usb.example.test", "ssh_port": 2222, "key": str(self.key)}
|
||||||
|
|
||||||
|
def config(self, exports=None, imports=None, server=True):
|
||||||
|
value = {"server": server, "exports": exports or [], "imports": imports or []}
|
||||||
|
self.m.atomic_json(self.m.CONFIG_DIR / "config.json", value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def test_a_usb_network_adapter_is_not_offered(self):
|
||||||
|
self.device("1-3", serial="nic-1")
|
||||||
|
(self.m.NET_SYSFS / "enx001122").mkdir()
|
||||||
|
(self.m.NET_SYSFS / "enx001122" / "device").symlink_to(self.m.SYSFS / "1-3" / "1-3:1.0")
|
||||||
|
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-3")["blocked"],
|
||||||
|
"USB network adapters cannot be shared")
|
||||||
|
self.device("1-4", serial="zigbee-9")
|
||||||
|
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-4")["blocked"], "")
|
||||||
|
|
||||||
|
def device(self, busid="1-2", serial="zigbee-123", device_class="00", interface="03", driver="usb"):
|
||||||
|
path = self.m.SYSFS / busid
|
||||||
|
path.mkdir()
|
||||||
|
values = {"idVendor": "10c4", "idProduct": "ea60", "serial": serial,
|
||||||
|
"product": "Test Zigbee adapter", "bDeviceClass": device_class}
|
||||||
|
for key, value in values.items():
|
||||||
|
(path / key).write_text(value)
|
||||||
|
child = path / f"{busid}:1.0"
|
||||||
|
child.mkdir()
|
||||||
|
(child / "bInterfaceClass").write_text(interface)
|
||||||
|
self.set_driver(busid, driver)
|
||||||
|
return next(d for d in self.m.inventory() if d["busid"] == busid)
|
||||||
|
|
||||||
|
def set_driver(self, busid, driver):
|
||||||
|
target = self.root / "drivers" / driver
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = self.m.SYSFS / busid / "driver"
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
path.symlink_to(target, target_is_directory=True)
|
||||||
|
|
||||||
|
def selected(self, device):
|
||||||
|
return {k: device[k] for k in ("busid", "vendor", "product", "serial", "name")} | {"id": EXPORT}
|
||||||
|
|
||||||
|
def usb(self, args, timeout=20):
|
||||||
|
self.calls.append(args)
|
||||||
|
if args[:2] == ["usbip", "bind"]:
|
||||||
|
self.set_driver(args[-1], "usbip-host")
|
||||||
|
self.m.MATCH_BUSID.slots[args[-1]] = "ALLOC"
|
||||||
|
elif args[:2] == ["usbip", "unbind"]:
|
||||||
|
self.set_driver(args[-1], "usb")
|
||||||
|
self.m.MATCH_BUSID.slots.pop(args[-1], None)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def test_storage_composite_interfaces_and_hubs_are_never_exported(self):
|
||||||
|
for index, (base, interface) in enumerate((("08", "06"), ("09", "00"), ("00", "08"), ("", "03"))):
|
||||||
|
device = self.device(busid=f"1-{index + 1}", device_class=base, interface=interface)
|
||||||
|
self.assertTrue(device["blocked"])
|
||||||
|
self.config()
|
||||||
|
with self.assertRaises(self.m.USBIPError), patch.object(self.m, "run") as command:
|
||||||
|
self.m.add_export(device["busid"])
|
||||||
|
command.assert_not_called()
|
||||||
|
self.assertEqual(self.m.load_config()["exports"], [])
|
||||||
|
|
||||||
|
def test_export_intent_is_private_and_bind_is_idempotent(self):
|
||||||
|
device = self.device()
|
||||||
|
self.config()
|
||||||
|
selected = self.m.add_export(device["busid"])
|
||||||
|
owned = {}
|
||||||
|
with patch.object(self.m, "run", side_effect=self.usb):
|
||||||
|
self.assertEqual(self.m.reconcile_exports(self.m.load_config(), owned), {})
|
||||||
|
self.assertEqual(self.m.reconcile_exports(self.m.load_config(), owned), {})
|
||||||
|
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
|
||||||
|
self.assertEqual(self.m.catalog()[0]["id"], selected["id"])
|
||||||
|
self.assertEqual(self.m.catalog()[0]["status"], "exported")
|
||||||
|
for path in (self.m.CONFIG_DIR / "config.json", self.m.state_path()):
|
||||||
|
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||||
|
self.assertNotEqual(self.m.RUN_DIR, self.m.SERVER_RUN_DIR)
|
||||||
|
|
||||||
|
def test_port_move_fails_closed_when_kernel_retains_old_slot(self):
|
||||||
|
selected = self.selected(self.device())
|
||||||
|
config = self.config([selected])
|
||||||
|
owned = {}
|
||||||
|
with patch.object(self.m, "run", side_effect=self.usb):
|
||||||
|
self.m.reconcile_exports(config, owned)
|
||||||
|
shutil.rmtree(self.m.SYSFS / "1-2")
|
||||||
|
self.device(serial="other-device", driver="usbip-host")
|
||||||
|
self.device(busid="2-4")
|
||||||
|
errors = self.m.reconcile_exports(config, owned)
|
||||||
|
self.assertIn("blocked", errors[EXPORT])
|
||||||
|
self.assertEqual(owned[EXPORT]["busid"], "1-2")
|
||||||
|
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
|
||||||
|
self.assertIsNone(self.m.catalog()[0]["busid"])
|
||||||
|
|
||||||
|
def test_retained_slot_is_rejected_before_detaching_reconnected_local_usb(self):
|
||||||
|
device = self.device()
|
||||||
|
selected = self.selected(device)
|
||||||
|
config = self.config([selected])
|
||||||
|
self.m.MATCH_BUSID.slots[device["busid"]] = "REMOV"
|
||||||
|
with patch.object(self.m, "run", side_effect=self.usb):
|
||||||
|
errors = self.m.reconcile_exports(config, {EXPORT: selected}, {})
|
||||||
|
self.assertIn("local driver", errors[EXPORT])
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.assertEqual(self.m.inventory()[0]["driver"], "usb")
|
||||||
|
|
||||||
|
def test_imported_usb_does_not_shadow_physical_original(self):
|
||||||
|
device = self.device()
|
||||||
|
imported = self.m.SYSFS / "2-1"
|
||||||
|
imported.symlink_to(self.m.SYSFS / "1-2", target_is_directory=True)
|
||||||
|
target = self.root / "vhci_hcd.0" / "2-1"
|
||||||
|
target.parent.mkdir()
|
||||||
|
shutil.copytree(self.m.SYSFS / "1-2", target, symlinks=True)
|
||||||
|
imported.unlink()
|
||||||
|
imported.symlink_to(target, target_is_directory=True)
|
||||||
|
devices = self.m.inventory()
|
||||||
|
self.assertEqual(len(devices), 1)
|
||||||
|
self.assertEqual(self.m.resolve_device(device, devices)["busid"], "1-2")
|
||||||
|
|
||||||
|
def test_no_serial_requires_same_busid_and_ambiguous_serial_is_rejected(self):
|
||||||
|
selected = self.selected(self.device(serial=""))
|
||||||
|
shutil.rmtree(self.m.SYSFS / "1-2")
|
||||||
|
self.device(busid="2-4", serial="")
|
||||||
|
with self.assertRaises(self.m.USBIPError):
|
||||||
|
self.m.resolve_device(selected, self.m.inventory())
|
||||||
|
self.device(busid="1-3", serial="duplicate")
|
||||||
|
first = self.device(busid="1-4", serial="duplicate")
|
||||||
|
with self.assertRaises(self.m.USBIPError):
|
||||||
|
self.m.resolve_device(first, self.m.inventory())
|
||||||
|
|
||||||
|
def test_foreign_exports_are_not_adopted_and_disable_only_releases_owned_devices(self):
|
||||||
|
own = self.selected(self.device())
|
||||||
|
foreign = self.device(busid="1-3", serial="foreign", driver="usbip-host")
|
||||||
|
config = self.config([own, self.selected(foreign) | {"id": CLIENT}])
|
||||||
|
owned = {}
|
||||||
|
with patch.object(self.m, "run", side_effect=self.usb):
|
||||||
|
errors = self.m.reconcile_exports(config, owned)
|
||||||
|
self.assertIn("outside ProxMenux", errors[CLIENT])
|
||||||
|
config["server"] = False
|
||||||
|
self.m.reconcile_exports(config, owned)
|
||||||
|
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"], ["usbip", "unbind", "--busid", "1-2"]])
|
||||||
|
self.assertEqual(owned, {})
|
||||||
|
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-3")["driver"], "usbip-host")
|
||||||
|
|
||||||
|
def test_recover_bind_intent_and_remove_selected_export(self):
|
||||||
|
selected = self.selected(self.device(driver="usbip-host"))
|
||||||
|
self.config([selected])
|
||||||
|
owned = {EXPORT: selected}
|
||||||
|
self.m.atomic_json(self.m.state_path(), owned)
|
||||||
|
with patch.object(self.m, "run", side_effect=self.usb):
|
||||||
|
self.m.reconcile_exports(self.m.load_config(), owned)
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
with patch.object(self.m.time, "sleep", side_effect=lambda _: self.m.reconcile_exports(self.m.load_config(), owned)):
|
||||||
|
self.m.remove_export(EXPORT)
|
||||||
|
self.m.reconcile_exports(self.m.load_config(), owned)
|
||||||
|
self.assertEqual(self.calls, [["usbip", "unbind", "--busid", "1-2"]])
|
||||||
|
|
||||||
|
def test_failed_bind_is_reported_and_retains_recoverable_intent(self):
|
||||||
|
selected = self.selected(self.device())
|
||||||
|
config = self.config([selected])
|
||||||
|
owned = {}
|
||||||
|
with patch.object(self.m, "run", side_effect=self.m.USBIPError("bind failed")):
|
||||||
|
self.assertEqual(self.m.reconcile_exports(config, owned), {EXPORT: "bind failed"})
|
||||||
|
self.assertIn(EXPORT, self.m.read_json(self.m.state_path(), {}))
|
||||||
|
self.assertEqual(self.m.status()["server_status"]["errors"], {EXPORT: "bind failed"})
|
||||||
|
self.assertEqual(self.m.catalog()[0]["status"], "waiting")
|
||||||
|
|
||||||
|
def test_unplugged_kernel_match_is_disarmed_but_not_falsely_forgotten(self):
|
||||||
|
selected = self.selected(self.device())
|
||||||
|
config = self.config([selected])
|
||||||
|
owned = {}
|
||||||
|
with patch.object(self.m, "run", side_effect=self.usb):
|
||||||
|
self.m.reconcile_exports(config, owned)
|
||||||
|
shutil.rmtree(self.m.SYSFS / "1-2")
|
||||||
|
errors = self.m.reconcile_exports(config, owned)
|
||||||
|
self.assertEqual(owned[EXPORT]["busid"], "1-2")
|
||||||
|
self.assertIn("blocked", errors[EXPORT])
|
||||||
|
self.assertEqual(self.m.MATCH_BUSID.slots["1-2"], "REMOV")
|
||||||
|
self.assertEqual(len(self.calls), 1)
|
||||||
|
|
||||||
|
def test_failed_stale_cleanup_does_not_forget_or_replace_ownership(self):
|
||||||
|
selected = self.selected(self.device())
|
||||||
|
config = self.config([selected])
|
||||||
|
owned = {EXPORT: selected}
|
||||||
|
shutil.rmtree(self.m.SYSFS / "1-2")
|
||||||
|
self.device(busid="2-4")
|
||||||
|
with patch.object(self.m, "disarm_match", side_effect=self.m.USBIPError("disarm failed")), patch.object(self.m, "run") as command:
|
||||||
|
errors = self.m.reconcile_exports(config, owned)
|
||||||
|
self.assertIn("disarm", errors[EXPORT])
|
||||||
|
self.assertEqual(owned[EXPORT]["busid"], "1-2")
|
||||||
|
command.assert_not_called()
|
||||||
|
|
||||||
|
def test_authorized_key_is_restricted_and_policy_stays_root_owned(self):
|
||||||
|
self.m.TRANSPORT_HOME = self.root / "transport-home"
|
||||||
|
account = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_dir=str(self.m.TRANSPORT_HOME))
|
||||||
|
public = self.root / "client.pub"
|
||||||
|
public.write_text("ssh-ed25519 AAAA test-client")
|
||||||
|
with patch.object(self.m.pwd, "getpwnam", side_effect=[KeyError(), account]), patch.object(
|
||||||
|
self.m, "run"
|
||||||
|
) as command, patch.object(self.m.os, "chown"), patch.object(self.m.os, "fchown"):
|
||||||
|
self.m.authorize_key(public)
|
||||||
|
command.assert_called_once()
|
||||||
|
self.assertEqual(command.call_args.args[0][0], "useradd")
|
||||||
|
policy = self.m.TRANSPORT_HOME / ".ssh/authorized_keys"
|
||||||
|
self.assertEqual(policy.stat().st_mode & 0o777, 0o640)
|
||||||
|
self.assertEqual(policy.read_text(), 'restrict,command="/usr/bin/python3 ' + self.m.REMOTE_SCRIPT + ' transport" ssh-ed25519 AAAA\n')
|
||||||
|
with patch.object(self.m.pwd, "getpwnam", return_value=account), patch.object(
|
||||||
|
self.m.os, "chown"
|
||||||
|
), patch.object(self.m.os, "fchown"):
|
||||||
|
self.m.authorize_key(public)
|
||||||
|
self.assertEqual(len(policy.read_text().splitlines()), 1)
|
||||||
|
|
||||||
|
def test_authorize_never_adopts_an_existing_foreign_account(self):
|
||||||
|
self.m.TRANSPORT_HOME = self.root / "foreign-home"
|
||||||
|
account = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_dir=str(self.m.TRANSPORT_HOME))
|
||||||
|
public = self.root / "client.pub"
|
||||||
|
public.write_text("ssh-ed25519 AAAA test-client")
|
||||||
|
with patch.object(self.m.pwd, "getpwnam", return_value=account), patch.object(self.m, "run") as command:
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "existing"):
|
||||||
|
self.m.authorize_key(public)
|
||||||
|
command.assert_not_called()
|
||||||
|
self.assertFalse(self.m.TRANSPORT_HOME.exists())
|
||||||
|
|
||||||
|
def test_forced_command_rejects_shell_and_wrong_identity(self):
|
||||||
|
for command in ("id", "catalog; id", "", "stream --socket /run/other.sock"):
|
||||||
|
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(
|
||||||
|
self.m.os, "geteuid", return_value=1234
|
||||||
|
), patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": command}), patch.object(self.m.os, "execvp") as execute:
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "Only USB/IP"):
|
||||||
|
self.m.transport()
|
||||||
|
execute.assert_not_called()
|
||||||
|
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m.os, "geteuid", return_value=0):
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "unprivileged"):
|
||||||
|
self.m.transport()
|
||||||
|
|
||||||
|
def test_forced_command_catalog_and_stream_are_fixed(self):
|
||||||
|
(self.m.SERVER_RUN_DIR / "catalog.json").write_text("[]")
|
||||||
|
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m.os, "geteuid", return_value=1234):
|
||||||
|
with patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": "catalog"}), patch("builtins.print") as output:
|
||||||
|
self.m.transport()
|
||||||
|
output.assert_called_once_with("[]")
|
||||||
|
with patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": "stream"}), patch.object(self.m.os, "execvp") as execute:
|
||||||
|
self.m.transport()
|
||||||
|
execute.assert_called_once_with("socat", ["socat", "STDIO", "UNIX-CONNECT:" + self.m.REMOTE_SOCKET])
|
||||||
|
|
||||||
|
def test_loopback_port_is_held_until_tunnel_stops(self):
|
||||||
|
with socket.socket() as unused:
|
||||||
|
unused.bind(("127.0.0.1", 0))
|
||||||
|
port = unused.getsockname()[1]
|
||||||
|
tunnel = self.m.LoopbackTunnel(self.remote | {"local_port": port})
|
||||||
|
try:
|
||||||
|
with socket.socket() as attacker:
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
attacker.bind(("127.0.0.1", port))
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "in use"):
|
||||||
|
self.m.LoopbackTunnel(self.remote | {"local_port": port})
|
||||||
|
finally:
|
||||||
|
tunnel.terminate()
|
||||||
|
self.assertEqual(tunnel.poll(), 0)
|
||||||
|
|
||||||
|
def test_namespace_guard_fails_before_spawning_a_listener(self):
|
||||||
|
with patch.object(self.m.os, "readlink", return_value="net:[1]"), patch.object(self.m.subprocess, "Popen") as process:
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "PrivateNetwork"):
|
||||||
|
self.m.serve()
|
||||||
|
process.assert_not_called()
|
||||||
|
|
||||||
|
def test_generated_units_are_isolated_quoted_and_owned(self):
|
||||||
|
self.m.SCRIPT = self.root / 'with space/%and$dollar"quote.py'
|
||||||
|
with patch("shutil.which", return_value="/usr/sbin/modprobe"):
|
||||||
|
server = self.m.unit_text("server")
|
||||||
|
client = self.m.unit_text("client", CLIENT)
|
||||||
|
self.assertIn("PrivateNetwork=yes", server)
|
||||||
|
self.assertIn("RuntimeDirectory=proxmenux-usbip-server", server)
|
||||||
|
self.assertIn("RuntimeDirectoryPreserve=yes", server)
|
||||||
|
self.assertIn('%%and$$dollar\\"quote.py" serve', server)
|
||||||
|
self.assertNotIn("PrivateNetwork=yes", client)
|
||||||
|
self.assertNotIn("RuntimeDirectory=", client)
|
||||||
|
self.assertIn("vhci-hcd", client)
|
||||||
|
self.assertIn("Restart=on-failure", client)
|
||||||
|
unit = self.m.UNIT_DIR / self.m.SERVER_UNIT
|
||||||
|
unit.write_text("[Unit]\nDescription=Someone else's service\n")
|
||||||
|
with self.assertRaises(self.m.USBIPError), patch.object(self.m, "run") as command:
|
||||||
|
self.m.update_server(True)
|
||||||
|
command.assert_not_called()
|
||||||
|
self.assertFalse((self.m.CONFIG_DIR / "config.json").exists())
|
||||||
|
|
||||||
|
def test_failed_server_start_restores_configuration_and_removes_new_unit(self):
|
||||||
|
previous = self.config(server=False)
|
||||||
|
def fail_start(args, timeout=20):
|
||||||
|
self.calls.append(args)
|
||||||
|
if args[:3] == ["systemctl", "enable", "--now"]:
|
||||||
|
raise self.m.USBIPError("start failed")
|
||||||
|
return ""
|
||||||
|
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m, "run", side_effect=fail_start), patch("shutil.which", return_value="/sbin/modprobe"):
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "start failed"):
|
||||||
|
self.m.update_server(True)
|
||||||
|
self.assertEqual(self.m.load_config(), previous)
|
||||||
|
self.assertFalse((self.m.UNIT_DIR / self.m.SERVER_UNIT).exists())
|
||||||
|
self.assertIn(["systemctl", "disable", "--now", self.m.SERVER_UNIT], self.calls)
|
||||||
|
|
||||||
|
def test_ssh_policy_and_argument_boundaries(self):
|
||||||
|
remote = self.m.validate_remote(self.remote["host"], 2222, str(self.key))
|
||||||
|
args = self.m.tunnel_args(remote | {"local_port": 33240})
|
||||||
|
for required in ("BatchMode=yes", "StrictHostKeyChecking=yes", "IdentitiesOnly=yes", "ForwardAgent=no",
|
||||||
|
"PasswordAuthentication=no", "ExitOnForwardFailure=yes"):
|
||||||
|
self.assertIn(required, args)
|
||||||
|
self.assertIn("proxmenux-usbip", args)
|
||||||
|
self.assertNotIn("root", args)
|
||||||
|
self.assertNotIn("-L", args)
|
||||||
|
self.assertNotIn("-N", args)
|
||||||
|
self.assertNotIn("StrictHostKeyChecking=no", args)
|
||||||
|
self.assertEqual(args[-3:], ["--", "usb.example.test", "stream"])
|
||||||
|
for host in ("-oProxyCommand=touch /tmp/no", "root@host", "host;id", "host\nother", "$(id)"):
|
||||||
|
with self.assertRaises(self.m.USBIPError):
|
||||||
|
self.m.validate_remote(host, 22, str(self.key))
|
||||||
|
self.key.chmod(0o644)
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "permissions"):
|
||||||
|
self.m.validate_remote("192.0.2.1", 22, str(self.key))
|
||||||
|
|
||||||
|
def test_ssh_credential_or_catalog_failure_never_saves_a_connection(self):
|
||||||
|
for failure in (self.m.USBIPError("Permission denied"), self.m.USBIPError("Connection timed out")):
|
||||||
|
with patch.object(self.m, "run", side_effect=failure):
|
||||||
|
with self.assertRaises(self.m.USBIPError):
|
||||||
|
self.m.add_import(self.remote, EXPORT, 33240)
|
||||||
|
self.assertFalse((self.m.CONFIG_DIR / "config.json").exists())
|
||||||
|
with patch.object(self.m, "run", return_value='[{"id":"--bad","busid":"1-2"}]'):
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "valid"):
|
||||||
|
self.m.remote_catalog(self.remote)
|
||||||
|
|
||||||
|
def test_import_service_failure_rolls_back_and_occupied_port_is_rejected(self):
|
||||||
|
exports = [{"id": EXPORT, "status": "exported", "busid": "1-2"}]
|
||||||
|
previous = self.config()
|
||||||
|
with patch.object(self.m, "remote_catalog", return_value=exports):
|
||||||
|
with socket.socket() as busy:
|
||||||
|
busy.bind(("127.0.0.1", 0))
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "in use"):
|
||||||
|
self.m.add_import(self.remote, EXPORT, busy.getsockname()[1])
|
||||||
|
with socket.socket() as unused:
|
||||||
|
unused.bind(("127.0.0.1", 0))
|
||||||
|
port = unused.getsockname()[1]
|
||||||
|
with patch("shutil.which", return_value="/sbin/modprobe"), patch.object(self.m, "enable_unit", side_effect=self.m.USBIPError("failed")):
|
||||||
|
with self.assertRaises(self.m.USBIPError):
|
||||||
|
self.m.add_import(self.remote, EXPORT, port)
|
||||||
|
self.assertEqual(self.m.load_config(), previous)
|
||||||
|
|
||||||
|
def port(self, **overrides):
|
||||||
|
return {"port": 2, "status": "Port in Use", "local_busid": "3-1", "host": "127.0.0.1", "tcp_port": 33240,
|
||||||
|
"busid": "1-2"} | overrides
|
||||||
|
|
||||||
|
def test_real_upstream_port_output_and_unowned_port_cleanup(self):
|
||||||
|
output = """Imported USB devices
|
||||||
|
====================
|
||||||
|
Port 02: <Port in Use> at High Speed(480Mbps)
|
||||||
|
Test Zigbee adapter (10c4:ea60)
|
||||||
|
3-1 -> usbip://127.0.0.1:33240/1-2
|
||||||
|
-> remote bus/dev 001/002
|
||||||
|
Port 03: <Port Error> at Full Speed(12Mbps)
|
||||||
|
Another adapter
|
||||||
|
3-2 -> usbip://192.0.2.1:3240/1-3
|
||||||
|
"""
|
||||||
|
ports = self.m.parse_ports(output)
|
||||||
|
self.assertEqual(ports[0], self.port())
|
||||||
|
self.assertEqual(ports[1]["status"], "Port Error")
|
||||||
|
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
|
||||||
|
self.m.atomic_json(owner, ports[0])
|
||||||
|
reused = self.port(local_busid="3-7")
|
||||||
|
with patch.object(self.m, "imported_ports", return_value=[reused, ports[1]]), patch.object(self.m, "run") as command:
|
||||||
|
self.m.release_import(owner)
|
||||||
|
command.assert_not_called()
|
||||||
|
self.assertFalse(owner.exists())
|
||||||
|
self.m.atomic_json(owner, ports[0])
|
||||||
|
with patch.object(self.m, "imported_ports", return_value=ports), patch.object(self.m, "run") as command:
|
||||||
|
self.m.release_import(owner)
|
||||||
|
command.assert_called_once_with(["usbip", "detach", "--port", "2"])
|
||||||
|
|
||||||
|
def test_attach_reuses_live_port_and_recovers_error_with_new_remote_busid(self):
|
||||||
|
selected = self.remote | {"id": CLIENT, "local_port": 33240, "export_id": EXPORT}
|
||||||
|
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
|
||||||
|
self.m.atomic_json(owner, self.port())
|
||||||
|
with patch.object(self.m, "imported_ports", return_value=[self.port()]), patch.object(self.m, "remote_catalog") as remote:
|
||||||
|
self.assertEqual(self.m.attach_once(selected, owner)["port"], 2)
|
||||||
|
remote.assert_not_called()
|
||||||
|
ports = [self.port(status="Port Error")]
|
||||||
|
def usb(args, timeout=20):
|
||||||
|
self.calls.append(args)
|
||||||
|
if "detach" in args:
|
||||||
|
ports.clear()
|
||||||
|
elif "attach" in args:
|
||||||
|
ports.append(self.port(busid="2-4", local_busid="3-5"))
|
||||||
|
return ""
|
||||||
|
with patch.object(self.m, "imported_ports", side_effect=lambda: copy.deepcopy(ports)), patch.object(
|
||||||
|
self.m, "remote_catalog", return_value=[{"id": EXPORT, "status": "exported", "busid": "2-4"}]
|
||||||
|
), patch.object(self.m, "run", side_effect=usb):
|
||||||
|
attached = self.m.attach_once(selected, owner)
|
||||||
|
self.assertEqual(attached["busid"], "2-4")
|
||||||
|
self.assertEqual(self.calls, [["usbip", "detach", "--port", "2"],
|
||||||
|
["usbip", "--tcp-port", "33240", "attach", "--remote", "127.0.0.1", "--busid", "2-4"]])
|
||||||
|
self.assertEqual(self.m.read_json(owner, None), attached)
|
||||||
|
|
||||||
|
def test_missing_remote_or_unowned_import_is_never_detached(self):
|
||||||
|
selected = self.remote | {"id": CLIENT, "local_port": 33240, "export_id": EXPORT}
|
||||||
|
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
|
||||||
|
with patch.object(self.m, "remote_catalog", return_value=[]), patch.object(self.m, "run") as command:
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "unplugged"):
|
||||||
|
self.m.attach_once(selected, owner)
|
||||||
|
command.assert_not_called()
|
||||||
|
with patch.object(self.m, "remote_catalog", return_value=[{"id": EXPORT, "status": "exported", "busid": "1-2"}]), patch.object(
|
||||||
|
self.m, "imported_ports", return_value=[self.port()]
|
||||||
|
), patch.object(self.m, "run") as command:
|
||||||
|
with self.assertRaisesRegex(self.m.USBIPError, "unowned"):
|
||||||
|
self.m.attach_once(selected, owner)
|
||||||
|
command.assert_not_called()
|
||||||
|
|
||||||
|
@unittest.skipUnless(sys.platform == "linux" and shutil.which("systemd-analyze"), "systemd verifier requires Linux")
|
||||||
|
def test_systemd_accepts_both_generated_service_files(self):
|
||||||
|
with patch("shutil.which", return_value="/bin/true"):
|
||||||
|
for name, content in ((self.m.SERVER_UNIT, self.m.unit_text("server")),
|
||||||
|
(self.m.unit_name(CLIENT), self.m.unit_text("client", CLIENT))):
|
||||||
|
path = self.m.UNIT_DIR / name
|
||||||
|
path.write_text(content)
|
||||||
|
result = subprocess.run(["systemd-analyze", "verify", str(path)], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_menu_and_search_expose_server_and_client_actions(self):
|
||||||
|
menu = (ROOT / "scripts/menus/hw_grafics_menu.sh").read_text()
|
||||||
|
self.assertIn('bash "$LOCAL_SCRIPTS/devices/usbip_menu.sh"', menu)
|
||||||
|
index = json.loads((ROOT / "scripts/menus/search_index.json").read_text())
|
||||||
|
self.assertTrue(any(i["script"] == "devices/usbip_menu.sh" and "usbip" in i["keywords"] for i in index))
|
||||||
|
for script in (ROOT / "scripts/devices/usbip_menu.sh", ROOT / "scripts/menus/hw_grafics_menu.sh"):
|
||||||
|
subprocess.run(["bash", "-n", str(script)], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Absent-slot recovery must require a verified pin and completed kernel removal."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import errno
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[3] / "scripts/devices/usbip_lifetime.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("usbip_lifetime", SOURCE)
|
||||||
|
l = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(l)
|
||||||
|
|
||||||
|
|
||||||
|
class LifetimeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
lease = l.Lease.__new__(l.Lease)
|
||||||
|
lease.path = self.root / "1-2"
|
||||||
|
lease.devpath = "/devices/test/usb1/1-2"
|
||||||
|
lease.bus = 1
|
||||||
|
lease.number = 3
|
||||||
|
lease.sequence = 100
|
||||||
|
lease.ready = True
|
||||||
|
lease.removed = False
|
||||||
|
lease.fds = []
|
||||||
|
lease.events = Mock()
|
||||||
|
lease.events.recvmsg.side_effect = BlockingIOError()
|
||||||
|
self.lease = lease
|
||||||
|
self.match = Mock()
|
||||||
|
self.match.parent = self.root
|
||||||
|
self.match.read_text.side_effect = lambda: (
|
||||||
|
"" if (self.root / "rebind").exists() else "1-2"
|
||||||
|
)
|
||||||
|
|
||||||
|
def event(self, **changes):
|
||||||
|
values = {
|
||||||
|
"ACTION": "remove",
|
||||||
|
"DEVTYPE": "usb_device",
|
||||||
|
"DEVPATH": self.lease.devpath,
|
||||||
|
"BUSNUM": "001",
|
||||||
|
"DEVNUM": "003",
|
||||||
|
"SEQNUM": "101",
|
||||||
|
} | changes
|
||||||
|
return b"\0".join(f"{k}={v}".encode() for k, v in values.items()) + b"\0"
|
||||||
|
|
||||||
|
def test_no_rebind_without_a_verified_lifetime_pin(self):
|
||||||
|
self.lease.ready = False
|
||||||
|
self.lease.removed = True
|
||||||
|
with self.assertRaises(l.LifetimeError):
|
||||||
|
self.lease.release(self.match, Mock())
|
||||||
|
self.assertFalse((self.root / "rebind").exists())
|
||||||
|
|
||||||
|
def test_rebind_requires_completed_removal_not_only_missing_sysfs(self):
|
||||||
|
with patch.object(
|
||||||
|
self.lease, "validate", side_effect=FileNotFoundError()
|
||||||
|
), patch.object(l.time, "monotonic", side_effect=[0, 3, 4]):
|
||||||
|
with self.assertRaisesRegex(l.LifetimeError, "Waiting"):
|
||||||
|
self.lease.release(self.match, Mock())
|
||||||
|
self.assertFalse((self.root / "rebind").exists())
|
||||||
|
|
||||||
|
def test_unrelated_or_userspace_events_do_not_authorize_slot_recovery(self):
|
||||||
|
packets = [
|
||||||
|
(self.event(SEQNUM="99"), [], 0, (0, 1)),
|
||||||
|
(self.event(DEVNUM="004"), [], 0, (0, 1)),
|
||||||
|
(self.event(DEVPATH="/devices/other"), [], 0, (0, 1)),
|
||||||
|
(self.event(), [], 0, (12345, 1)),
|
||||||
|
(self.event(DEVTYPE="usb_interface"), [], 0, (0, 1)),
|
||||||
|
BlockingIOError(),
|
||||||
|
]
|
||||||
|
self.lease.events.recvmsg.side_effect = packets
|
||||||
|
self.assertFalse(self.lease.poll_removed())
|
||||||
|
|
||||||
|
def test_lost_or_truncated_events_invalidate_recovery(self):
|
||||||
|
for fault in (
|
||||||
|
OSError(errno.ENOBUFS, "overflow"),
|
||||||
|
(self.event(), [], socket.MSG_TRUNC, (0, 1)),
|
||||||
|
):
|
||||||
|
self.lease.ready = True
|
||||||
|
self.lease.events.recvmsg.side_effect = [fault]
|
||||||
|
with self.assertRaises(l.LifetimeError):
|
||||||
|
self.lease.poll_removed()
|
||||||
|
self.assertFalse(self.lease.ready)
|
||||||
|
self.assertFalse((self.root / "rebind").exists())
|
||||||
|
|
||||||
|
def test_pinned_completed_removal_cleans_only_its_slot(self):
|
||||||
|
self.lease.events.recvmsg.side_effect = [
|
||||||
|
(self.event(), [], 0, (0, 1)),
|
||||||
|
BlockingIOError(),
|
||||||
|
]
|
||||||
|
unbind = Mock()
|
||||||
|
self.lease.release(self.match, unbind)
|
||||||
|
unbind.assert_not_called()
|
||||||
|
self.assertEqual((self.root / "rebind").read_text(), "1-2")
|
||||||
|
self.assertFalse(self.lease.ready)
|
||||||
|
|
||||||
|
def test_present_device_uses_native_unbind_not_absent_recovery(self):
|
||||||
|
self.match.read_text.return_value = ""
|
||||||
|
self.match.read_text.side_effect = None
|
||||||
|
unbind = Mock()
|
||||||
|
with patch.object(self.lease, "validate"):
|
||||||
|
self.lease.release(self.match, unbind)
|
||||||
|
unbind.assert_called_once_with("1-2")
|
||||||
|
self.assertFalse((self.root / "rebind").exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
"""Real socket control/URB tests with disposable sysfs-shaped device objects."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"usbip_protocol", ROOT / "scripts/devices/usbip_protocol.py"
|
||||||
|
)
|
||||||
|
p = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(p)
|
||||||
|
|
||||||
|
|
||||||
|
def descriptor(
|
||||||
|
name="2-1", bus=2, number=3, vendor=0x1D6B, product=0x0104, interfaces=1
|
||||||
|
):
|
||||||
|
return p.DEVICE.pack(
|
||||||
|
b"/sys/devices/disposable",
|
||||||
|
name.encode(),
|
||||||
|
bus,
|
||||||
|
number,
|
||||||
|
3,
|
||||||
|
vendor,
|
||||||
|
product,
|
||||||
|
0x0100,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
interfaces,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exact(connection, count):
|
||||||
|
result = bytearray()
|
||||||
|
while len(result) < count:
|
||||||
|
block = connection.recv(count - len(result))
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
result.extend(block)
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
class GateTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory(prefix="usbip-gate-")
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.sysfs = self.root / "sysfs"
|
||||||
|
self.sysfs.mkdir()
|
||||||
|
self.device("original")
|
||||||
|
self.selected = {
|
||||||
|
"2-1": {
|
||||||
|
"id": "abcdef123456",
|
||||||
|
"vendor": "1d6b",
|
||||||
|
"product": "0104",
|
||||||
|
"serial": "selected-serial",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.calls = []
|
||||||
|
self.reply = descriptor()
|
||||||
|
self.listed = [
|
||||||
|
(descriptor(), b"\x02\x02\x01\0"),
|
||||||
|
(descriptor("3-1", bus=3, number=4), b"\x02\x02\x01\0"),
|
||||||
|
]
|
||||||
|
self.hook = lambda: None
|
||||||
|
self.header_override = None
|
||||||
|
fixture = self
|
||||||
|
|
||||||
|
class Handler(socketserver.BaseRequestHandler):
|
||||||
|
def handle(self):
|
||||||
|
try:
|
||||||
|
header = exact(self.request, 8)
|
||||||
|
if len(header) != 8:
|
||||||
|
return
|
||||||
|
_, code, _ = p.HEADER.unpack(header)
|
||||||
|
request = header + (
|
||||||
|
exact(self.request, 32) if code == p.IMPORT else b""
|
||||||
|
)
|
||||||
|
fixture.calls.append(request)
|
||||||
|
fixture.hook()
|
||||||
|
self.request.sendall(
|
||||||
|
fixture.header_override
|
||||||
|
or p.HEADER.pack(p.VERSION, code & 0x7FFF, 0)
|
||||||
|
)
|
||||||
|
if code == p.DEVLIST:
|
||||||
|
self.request.sendall(struct.pack("!I", len(fixture.listed)))
|
||||||
|
for raw, interfaces in fixture.listed:
|
||||||
|
self.request.sendall(raw + interfaces)
|
||||||
|
return
|
||||||
|
self.request.sendall(fixture.reply)
|
||||||
|
while True:
|
||||||
|
block = self.request.recv(65536)
|
||||||
|
if not block:
|
||||||
|
self.request.shutdown(socket.SHUT_WR)
|
||||||
|
return
|
||||||
|
self.request.sendall(block)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
class Backend(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
self.backend = Backend(("127.0.0.1", 0), Handler)
|
||||||
|
self.backend_thread = threading.Thread(
|
||||||
|
target=self.backend.serve_forever,
|
||||||
|
kwargs={"poll_interval": 0.05},
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self.backend_thread.start()
|
||||||
|
self.addCleanup(self.backend.server_close)
|
||||||
|
self.addCleanup(self.backend.shutdown)
|
||||||
|
# Production calls this as root; these protocol tests do not alter users.
|
||||||
|
with patch.object(p.os, "chown"):
|
||||||
|
self.gate = p.Gate(
|
||||||
|
self.root / "gate.sock",
|
||||||
|
os.getgid(),
|
||||||
|
lambda name: self.selected.get(name),
|
||||||
|
self.sysfs,
|
||||||
|
self.backend.server_address,
|
||||||
|
max_connections=2,
|
||||||
|
)
|
||||||
|
self.addCleanup(self.gate.terminate)
|
||||||
|
|
||||||
|
def device(self, directory, serial="selected-serial", number=3):
|
||||||
|
device = self.root / directory
|
||||||
|
device.mkdir()
|
||||||
|
for name, value in {
|
||||||
|
"idVendor": "1d6b",
|
||||||
|
"idProduct": "0104",
|
||||||
|
"serial": serial,
|
||||||
|
"busnum": "2",
|
||||||
|
"devnum": str(number),
|
||||||
|
"bDeviceClass": "00",
|
||||||
|
}.items():
|
||||||
|
(device / name).write_text(value)
|
||||||
|
interface = device / "2-1:1.0"
|
||||||
|
interface.mkdir()
|
||||||
|
(interface / "bInterfaceClass").write_text("02")
|
||||||
|
(device / "driver").symlink_to("/sys/bus/usb/drivers/usbip-host")
|
||||||
|
link = self.sysfs / "2-1"
|
||||||
|
link.unlink(missing_ok=True)
|
||||||
|
link.symlink_to(device, target_is_directory=True)
|
||||||
|
return device
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
connection = socket.socket(socket.AF_UNIX)
|
||||||
|
connection.settimeout(3)
|
||||||
|
connection.connect(str(self.gate.path))
|
||||||
|
self.addCleanup(connection.close)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def request(self, name=b"2-1\0", code=p.IMPORT):
|
||||||
|
return p.HEADER.pack(p.VERSION, code, 0) + (
|
||||||
|
name.ljust(32, b"\0") if code == p.IMPORT else b""
|
||||||
|
)
|
||||||
|
|
||||||
|
def reject(self, request):
|
||||||
|
connection = self.connect()
|
||||||
|
connection.sendall(request)
|
||||||
|
result = exact(connection, 8)
|
||||||
|
self.assertTrue(not result or p.HEADER.unpack(result)[2] != 0, result)
|
||||||
|
self.assertEqual(connection.recv(1), b"")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_fragmented_selected_import_and_large_binary_full_duplex(self):
|
||||||
|
connection = self.connect()
|
||||||
|
for value in self.request():
|
||||||
|
connection.sendall(bytes([value]))
|
||||||
|
self.assertEqual(
|
||||||
|
exact(connection, 320), p.HEADER.pack(p.VERSION, 3, 0) + self.reply
|
||||||
|
)
|
||||||
|
payload = bytes(range(256)) * 4096
|
||||||
|
writer = threading.Thread(target=connection.sendall, args=(payload,))
|
||||||
|
writer.start()
|
||||||
|
self.assertEqual(exact(connection, len(payload)), payload)
|
||||||
|
writer.join(3)
|
||||||
|
self.assertFalse(writer.is_alive())
|
||||||
|
connection.shutdown(socket.SHUT_WR)
|
||||||
|
self.assertEqual(connection.recv(1), b"")
|
||||||
|
|
||||||
|
def test_foreign_device_is_rejected_before_native_backend_import(self):
|
||||||
|
self.reject(self.request(b"3-1\0"))
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_malformed_busids_never_reach_backend(self):
|
||||||
|
for value in (b"2-1\0x", b"../../etc\0", b"2-1\xff\0", b"x" * 32):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.reject(self.request(value))
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_unsupported_control_headers_never_reach_backend(self):
|
||||||
|
for header in (
|
||||||
|
p.HEADER.pack(0x0110, p.IMPORT, 0),
|
||||||
|
p.HEADER.pack(p.VERSION, 0x8001, 0),
|
||||||
|
p.HEADER.pack(p.VERSION, p.IMPORT, 1),
|
||||||
|
):
|
||||||
|
self.reject(header)
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_devlist_filters_unselected_export_and_preserves_interface_records(self):
|
||||||
|
connection = self.connect()
|
||||||
|
connection.sendall(self.request(code=p.DEVLIST))
|
||||||
|
reply = exact(connection, 12)
|
||||||
|
self.assertEqual(reply, p.HEADER.pack(p.VERSION, 5, 0) + struct.pack("!I", 1))
|
||||||
|
self.assertEqual(exact(connection, 316), self.reply + b"\x02\x02\x01\0")
|
||||||
|
self.assertEqual(connection.recv(1), b"")
|
||||||
|
|
||||||
|
def test_changed_selection_during_import_receives_no_success_payload(self):
|
||||||
|
self.hook = self.selected.clear
|
||||||
|
self.reject(self.request())
|
||||||
|
self.assertEqual(len(self.calls), 1)
|
||||||
|
|
||||||
|
def test_mutated_callback_dictionary_cannot_rewrite_saved_selection(self):
|
||||||
|
self.hook = lambda: self.selected["2-1"].update(id="fedcba654321")
|
||||||
|
self.reject(self.request())
|
||||||
|
|
||||||
|
def test_replaced_sysfs_object_cannot_use_identical_reply_metadata(self):
|
||||||
|
self.hook = lambda: self.device("replacement")
|
||||||
|
self.reject(self.request())
|
||||||
|
|
||||||
|
def test_driver_change_during_import_is_rejected(self):
|
||||||
|
def replace():
|
||||||
|
driver = self.root / "original/driver"
|
||||||
|
driver.unlink()
|
||||||
|
driver.symlink_to("/sys/bus/usb/drivers/usb")
|
||||||
|
|
||||||
|
self.hook = replace
|
||||||
|
self.reject(self.request())
|
||||||
|
|
||||||
|
def test_descriptor_identity_and_devnum_must_match_pinned_object(self):
|
||||||
|
for reply in (
|
||||||
|
descriptor(number=4),
|
||||||
|
descriptor(vendor=0x05AC),
|
||||||
|
descriptor("3-1"),
|
||||||
|
descriptor(bus=3),
|
||||||
|
):
|
||||||
|
self.reply = reply
|
||||||
|
self.reject(self.request())
|
||||||
|
|
||||||
|
def test_composite_storage_or_hub_device_is_rejected_before_backend(self):
|
||||||
|
for name in ("bDeviceClass", "2-1:1.0/bInterfaceClass"):
|
||||||
|
path = self.root / "original" / name
|
||||||
|
old = path.read_text()
|
||||||
|
for value in ("08", "09"):
|
||||||
|
path.write_text(value)
|
||||||
|
self.reject(self.request())
|
||||||
|
path.write_text(old)
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_unicode_serial_is_valid_without_changing_busid_encoding(self):
|
||||||
|
serial = "selected-ñ-设备"
|
||||||
|
(self.root / "original/serial").write_text(serial)
|
||||||
|
self.selected["2-1"]["serial"] = serial
|
||||||
|
connection = self.connect()
|
||||||
|
connection.sendall(self.request())
|
||||||
|
self.assertEqual(
|
||||||
|
exact(connection, 320), p.HEADER.pack(p.VERSION, 3, 0) + self.reply
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backend_response_version_opcode_and_status_are_validated(self):
|
||||||
|
for header in (
|
||||||
|
p.HEADER.pack(0x0110, 3, 0),
|
||||||
|
p.HEADER.pack(p.VERSION, 5, 0),
|
||||||
|
p.HEADER.pack(p.VERSION, 3, 0xFFFFFFFF),
|
||||||
|
):
|
||||||
|
self.header_override = header
|
||||||
|
self.reject(self.request())
|
||||||
|
|
||||||
|
def test_incomplete_header_times_out_but_accepted_idle_stream_does_not(self):
|
||||||
|
with patch.object(p, "CONTROL_SECONDS", 0.1):
|
||||||
|
partial = self.connect()
|
||||||
|
partial.sendall(b"\x01")
|
||||||
|
self.assertEqual(partial.recv(1), b"")
|
||||||
|
connection = self.connect()
|
||||||
|
connection.sendall(self.request())
|
||||||
|
self.assertEqual(len(exact(connection, 320)), 320)
|
||||||
|
time.sleep(0.2)
|
||||||
|
connection.sendall(b"idle-still-works")
|
||||||
|
self.assertEqual(exact(connection, 16), b"idle-still-works")
|
||||||
|
|
||||||
|
def test_stop_closes_idle_backend_and_client_and_removes_listener(self):
|
||||||
|
connection = self.connect()
|
||||||
|
connection.sendall(self.request())
|
||||||
|
self.assertEqual(len(exact(connection, 320)), 320)
|
||||||
|
self.gate.terminate()
|
||||||
|
self.assertEqual(connection.recv(1), b"")
|
||||||
|
self.assertFalse(self.gate.path.exists())
|
||||||
|
self.assertIsNotNone(self.gate.poll())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -15,7 +15,7 @@ class VmBackupJobsWiring(TestCase):
|
|||||||
source = ROUTES.read_text()
|
source = ROUTES.read_text()
|
||||||
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
|
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), source.count('@vm_backup_bp.route('))
|
||||||
self.assertEqual(len(routes), 8)
|
self.assertEqual(len(routes), 10)
|
||||||
for path, method, guard, name in routes:
|
for path, method, guard, name in routes:
|
||||||
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
|
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
|
||||||
self.assertEqual(guard, expected, f'{method} {path} ({name})')
|
self.assertEqual(guard, expected, f'{method} {path} ({name})')
|
||||||
@@ -24,7 +24,10 @@ class VmBackupJobsWiring(TestCase):
|
|||||||
source = ROUTES.read_text()
|
source = ROUTES.read_text()
|
||||||
self.assertNotIn('shell=True', source)
|
self.assertNotIn('shell=True', source)
|
||||||
self.assertNotIn('os.system', source)
|
self.assertNotIn('os.system', source)
|
||||||
self.assertEqual(source.count('subprocess.run('), 1)
|
# pvesh, and the transient unit a backup started by hand runs in.
|
||||||
|
programs = re.findall(r'subprocess\.run\(\s*\[\s*"([a-z-]+)"', source)
|
||||||
|
self.assertEqual(len(programs), source.count('subprocess.run('))
|
||||||
|
self.assertEqual(set(programs), {'pvesh', 'systemd-run', 'systemctl'})
|
||||||
self.assertIn('subprocess.run(["pvesh", *args]', source)
|
self.assertIn('subprocess.run(["pvesh", *args]', source)
|
||||||
|
|
||||||
def test_the_monitor_ships_and_registers_it(self):
|
def test_the_monitor_ships_and_registers_it(self):
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
name: Build web documentation translations
|
name: Build web documentation translations
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# Source and locale corrections are reviewed together. An automatic run
|
||||||
branches: [develop]
|
# would treat the changed English fingerprints as stale translations and
|
||||||
paths:
|
# overwrite those reviewed locale strings with provider output.
|
||||||
- 'web/messages/en/*.json'
|
|
||||||
- 'web/messages/en/**/*.json'
|
|
||||||
- '.github/scripts/build_web_docs_i18n.py'
|
|
||||||
- '.github/scripts/build_translation_cache.py'
|
|
||||||
- '.github/workflows/build-web-docs-i18n.yml'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
languages:
|
languages:
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: develop
|
ref: develop
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
# A newer push to the same branch or pull request replaces the run in progress.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -32,7 +37,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -45,5 +49,6 @@ jobs:
|
|||||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
BASE: ${{ github.event.pull_request.base.sha }}
|
BASE: ${{ github.event.pull_request.base.sha }}
|
||||||
run: |
|
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"
|
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"
|
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
name: Native Monitor AppImage validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/test-appimage-native.yml'
|
||||||
|
- 'AppImage/scripts/build_appimage.sh'
|
||||||
|
- 'AppImage/scripts/appimage_arch.sh'
|
||||||
|
- 'AppImage/scripts/borg_native_arm.sh'
|
||||||
|
- '.github/scripts/smoke_native_apprun.sh'
|
||||||
|
- '.github/scripts/check_appimage_elf.py'
|
||||||
|
- 'AppImage/scripts/AppRun'
|
||||||
|
- 'tests/test_appimage_arch.py'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: native-appimage-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
native-build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runner: ubuntu-22.04
|
||||||
|
arch: x86_64
|
||||||
|
- runner: ubuntu-22.04-arm
|
||||||
|
arch: aarch64
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Validate native architecture selection
|
||||||
|
run: |
|
||||||
|
test "$(uname -m)" = '${{ matrix.arch }}'
|
||||||
|
python -B -m unittest discover -s tests -p test_appimage_arch.py -v
|
||||||
|
- name: Refresh package indexes for bundled monitoring tools
|
||||||
|
# Third-party runner repositories occasionally fail; the build reports missing tools.
|
||||||
|
run: sudo apt-get update || true
|
||||||
|
- name: Build native AppImage without publishing it
|
||||||
|
run: bash AppImage/scripts/build_appimage.sh
|
||||||
|
- name: Reject foreign ELF payloads
|
||||||
|
run: python .github/scripts/check_appimage_elf.py /tmp/proxmenux_build/ProxMenux.AppDir '${{ matrix.arch }}'
|
||||||
|
- name: Extract native AppImage without FUSE
|
||||||
|
run: |
|
||||||
|
mkdir /tmp/proxmenux-native-extract
|
||||||
|
cd /tmp/proxmenux-native-extract
|
||||||
|
image=$(find "$GITHUB_WORKSPACE/AppImage/dist" -name '*.AppImage' -type f -print -quit)
|
||||||
|
test -n "$image"
|
||||||
|
chmod +x "$image"
|
||||||
|
"$image" --appimage-extract
|
||||||
|
test -f squashfs-root/usr/bin/flask_server.py
|
||||||
|
test -f squashfs-root/usr/bin/appimage_arch.sh
|
||||||
|
test -f squashfs-root/web/index.html
|
||||||
|
- name: Import bundled Python 3.11 native WebSocket runtime
|
||||||
|
env:
|
||||||
|
PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp311:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages
|
||||||
|
run: |
|
||||||
|
python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)'
|
||||||
|
bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
- name: Import bundled Python 3.13 native WebSocket runtime
|
||||||
|
env:
|
||||||
|
PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp313:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages
|
||||||
|
run: |
|
||||||
|
python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)'
|
||||||
|
bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
name: Feature tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/test-features.yml'
|
||||||
|
- 'AppImage/scripts/notification_manager.py'
|
||||||
|
- 'AppImage/scripts/notification_templates.py'
|
||||||
|
- 'AppImage/scripts/ai_context_enrichment.py'
|
||||||
|
- 'AppImage/scripts/ai_providers/**'
|
||||||
|
- 'AppImage/config/verified_ai_models.json'
|
||||||
|
- 'AppImage/scripts/tests/test_groq_model_recommendations.py'
|
||||||
|
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
|
||||||
|
- 'AppImage/scripts/notification_channels.py'
|
||||||
|
- 'AppImage/components/apprise-service-help.tsx'
|
||||||
|
- 'AppImage/components/notification-settings.tsx'
|
||||||
|
- 'tests/test_apprise_services.py'
|
||||||
|
- 'tests/test_apprise_service_help.cjs'
|
||||||
|
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||||
|
- 'AppImage/components/backup-storage-retention.tsx'
|
||||||
|
- 'AppImage/components/backup-tab.tsx'
|
||||||
|
- 'AppImage/components/vm-backup-jobs.tsx'
|
||||||
|
- 'AppImage/lib/backup-retention.ts'
|
||||||
|
- 'tests/test_vm_backup_jobs_api.py'
|
||||||
|
- 'tests/test_backup_storage_retention.cjs'
|
||||||
|
- 'tests/test_figurine_installation.py'
|
||||||
|
- 'scripts/gpu_tpu/nvidia_installer.sh'
|
||||||
|
- 'tests/test_nvidia*.py'
|
||||||
|
- 'AppImage/scripts/managed_installs.py'
|
||||||
|
- 'scripts/post_install/customizable_post_install.sh'
|
||||||
|
- 'AppImage/scripts/post_install_versions.py'
|
||||||
|
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||||
|
- 'AppImage/scripts/guest_backup_archives.py'
|
||||||
|
- 'AppImage/scripts/flask_server.py'
|
||||||
|
- 'AppImage/scripts/ovs_topology.py'
|
||||||
|
- 'AppImage/scripts/tests/test_ovs_topology.py'
|
||||||
|
- 'AppImage/components/network-flow.tsx'
|
||||||
|
- 'AppImage/components/network-metrics.tsx'
|
||||||
|
- 'AppImage/tests/ovs-network-flow.cjs'
|
||||||
|
- 'AppImage/scripts/jwt_middleware.py'
|
||||||
|
- 'AppImage/scripts/build_appimage.sh'
|
||||||
|
- 'AppImage/components/guest-backup-list.tsx'
|
||||||
|
- 'AppImage/components/virtual-machines.tsx'
|
||||||
|
- 'AppImage/lib/guest-backup-archives.ts'
|
||||||
|
- 'AppImage/messages/**'
|
||||||
|
- 'tests/test_guest_backup_archives.*'
|
||||||
|
- 'AppImage/scripts/lvm_device_scope.py'
|
||||||
|
- 'AppImage/scripts/disk_temperature_history.py'
|
||||||
|
- 'AppImage/scripts/health_monitor.py'
|
||||||
|
- 'AppImage/scripts/flask_notification_routes.py'
|
||||||
|
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||||
|
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||||
|
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||||
|
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||||
|
- 'tests/test_lvm_disk_read_policy.py'
|
||||||
|
- 'oci/**'
|
||||||
|
- 'AppImage/scripts/oci_menu_bridge.py'
|
||||||
|
- 'AppImage/scripts/lxc_apps.py'
|
||||||
|
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
|
||||||
|
- 'AppImage/scripts/*pbs*.py'
|
||||||
|
- 'AppImage/scripts/tests/test_pbs.py'
|
||||||
|
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||||
|
- 'AppImage/scripts/auth_manager.py'
|
||||||
|
- 'AppImage/components/pbs-*.tsx'
|
||||||
|
- 'AppImage/components/proxmox-dashboard.tsx'
|
||||||
|
- 'AppImage/tests/pbs-navigation.cjs'
|
||||||
|
- 'scripts/global/proxmox_platform.py'
|
||||||
|
- 'scripts/global/platform-functions.sh'
|
||||||
|
- 'scripts/global/repository-functions.sh'
|
||||||
|
- 'scripts/menus/pbs_menu.sh'
|
||||||
|
- 'scripts/utilities/pbs_update.sh'
|
||||||
|
- 'scripts/global/update-pbs.sh'
|
||||||
|
- 'scripts/menus/menu_post_install.sh'
|
||||||
|
- 'scripts/post_install/*.sh'
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/test-features.yml'
|
||||||
|
- 'AppImage/scripts/notification_manager.py'
|
||||||
|
- 'AppImage/scripts/notification_templates.py'
|
||||||
|
- 'AppImage/scripts/ai_context_enrichment.py'
|
||||||
|
- 'AppImage/scripts/ai_providers/**'
|
||||||
|
- 'AppImage/config/verified_ai_models.json'
|
||||||
|
- 'AppImage/scripts/tests/test_groq_model_recommendations.py'
|
||||||
|
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
|
||||||
|
- 'AppImage/scripts/notification_channels.py'
|
||||||
|
- 'AppImage/components/apprise-service-help.tsx'
|
||||||
|
- 'tests/test_apprise_services.py'
|
||||||
|
- 'tests/test_apprise_service_help.cjs'
|
||||||
|
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||||
|
- 'AppImage/components/backup-storage-retention.tsx'
|
||||||
|
- 'AppImage/lib/backup-retention.ts'
|
||||||
|
- 'tests/test_vm_backup_jobs_api.py'
|
||||||
|
- 'tests/test_backup_storage_retention.cjs'
|
||||||
|
- 'tests/test_figurine_installation.py'
|
||||||
|
- 'scripts/post_install/customizable_post_install.sh'
|
||||||
|
- 'AppImage/scripts/post_install_versions.py'
|
||||||
|
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||||
|
- 'AppImage/scripts/guest_backup_archives.py'
|
||||||
|
- 'AppImage/scripts/flask_server.py'
|
||||||
|
- 'AppImage/scripts/ovs_topology.py'
|
||||||
|
- 'AppImage/scripts/tests/test_ovs_topology.py'
|
||||||
|
- 'AppImage/components/network-flow.tsx'
|
||||||
|
- 'AppImage/components/network-metrics.tsx'
|
||||||
|
- 'AppImage/tests/ovs-network-flow.cjs'
|
||||||
|
- 'AppImage/scripts/jwt_middleware.py'
|
||||||
|
- 'AppImage/components/guest-backup-list.tsx'
|
||||||
|
- 'AppImage/components/virtual-machines.tsx'
|
||||||
|
- 'AppImage/lib/guest-backup-archives.ts'
|
||||||
|
- 'tests/test_guest_backup_archives.*'
|
||||||
|
- 'AppImage/scripts/lvm_device_scope.py'
|
||||||
|
- 'AppImage/scripts/disk_temperature_history.py'
|
||||||
|
- 'AppImage/scripts/health_monitor.py'
|
||||||
|
- 'AppImage/scripts/flask_notification_routes.py'
|
||||||
|
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||||
|
- 'AppImage/scripts/build_appimage.sh'
|
||||||
|
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||||
|
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||||
|
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||||
|
- 'tests/test_lvm_disk_read_policy.py'
|
||||||
|
- 'scripts/gpu_tpu/nvidia_installer.sh'
|
||||||
|
- 'tests/test_nvidia*.py'
|
||||||
|
- 'AppImage/scripts/managed_installs.py'
|
||||||
|
- 'oci/**'
|
||||||
|
- 'AppImage/scripts/oci_menu_bridge.py'
|
||||||
|
- 'AppImage/scripts/lxc_apps.py'
|
||||||
|
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
|
||||||
|
- 'AppImage/scripts/*pbs*.py'
|
||||||
|
- 'scripts/global/proxmox_platform.py'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# A newer push to the same branch or pull request replaces the run in progress.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
apprise-services:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
apprise: ['1.7.0', '2.0.1']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22.14.0'
|
||||||
|
- name: Install fixture dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install 'apprise==${{ matrix.apprise }}'
|
||||||
|
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||||
|
- name: Test real provider plugins with mocked HTTP
|
||||||
|
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
|
||||||
|
- name: Render provider help and locale fallback
|
||||||
|
env:
|
||||||
|
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||||
|
run: node tests/test_apprise_service_help.cjs
|
||||||
|
|
||||||
|
storage-retention:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22.14.0'
|
||||||
|
- name: Install fixture dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install 'Flask==3.1.2'
|
||||||
|
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||||
|
- name: Test native job and storage configuration APIs
|
||||||
|
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
|
||||||
|
- name: Test shared retention properties
|
||||||
|
env:
|
||||||
|
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||||
|
run: node tests/test_backup_storage_retention.cjs
|
||||||
|
|
||||||
|
nvidia-installer:
|
||||||
|
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 NVIDIA parser, cache and Secure Boot regressions
|
||||||
|
run: |
|
||||||
|
bash -n scripts/gpu_tpu/nvidia_installer.sh
|
||||||
|
python3 -B -m unittest discover -s tests -p 'test_nvidia*.py' -v
|
||||||
|
|
||||||
|
ai-model-availability:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 3
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Test model ordering and curated availability without network or credentials
|
||||||
|
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_groq_model_recommendations.py -v
|
||||||
|
|
||||||
|
installer-and-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Check shell syntax and isolated installer regressions
|
||||||
|
run: |
|
||||||
|
bash -n scripts/post_install/customizable_post_install.sh
|
||||||
|
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
|
||||||
|
- name: Install ARM user-mode emulation for release smoke tests
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y --no-install-recommends qemu-user
|
||||||
|
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
|
||||||
|
env:
|
||||||
|
FIGURINE_VERSION: '2.1.1'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
scratch="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
while read -r arch sha; do
|
||||||
|
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
|
||||||
|
target="$scratch/$arch"
|
||||||
|
mkdir -p "$target"
|
||||||
|
curl --fail --location --retry 3 --output "$target/$archive" \
|
||||||
|
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
|
||||||
|
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
|
||||||
|
tar -xf "$target/$archive" -C "$target"
|
||||||
|
runner=()
|
||||||
|
case "$arch" in
|
||||||
|
arm64) runner=(qemu-aarch64) ;;
|
||||||
|
arm) runner=(qemu-arm) ;;
|
||||||
|
esac
|
||||||
|
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
|
||||||
|
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
|
||||||
|
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
|
||||||
|
test -s "$target/banner"
|
||||||
|
printf 'PASS: %s version and login banner\n' "$arch"
|
||||||
|
done <<'ASSETS'
|
||||||
|
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
|
||||||
|
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
|
||||||
|
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
|
||||||
|
ASSETS
|
||||||
|
|
||||||
|
guest-backups:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22.14.0'
|
||||||
|
- name: Install fixture dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install 'Flask==3.1.2'
|
||||||
|
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||||
|
- name: Test native deletion, protection, authorization and cache invalidation
|
||||||
|
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
|
||||||
|
- name: Test frontend deletion and completion handling
|
||||||
|
env:
|
||||||
|
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||||
|
run: node tests/test_guest_backup_archives.cjs
|
||||||
|
|
||||||
|
disk-policy:
|
||||||
|
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: Test automatic LVM readers with mocked inventory and policy
|
||||||
|
run: python -I -B -m unittest discover -s tests -p test_lvm_disk_read_policy.py -v
|
||||||
|
- name: Test backup node ownership and former jobs
|
||||||
|
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_host_backup_node.py -v
|
||||||
|
- name: Test local node lookup without Proxmox API commands
|
||||||
|
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_proxmox_node_name.py -v
|
||||||
|
- name: Test notification configuration writes against a cluster filesystem fixture
|
||||||
|
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_pve_notifications_cfg_writes.py -v
|
||||||
|
- name: Test AI diagnostic explanations and backup inventory integrity
|
||||||
|
run: |
|
||||||
|
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_backup_ai_diagnostics.py -v
|
||||||
|
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_vzdump_ai_integrity.py -v
|
||||||
|
|
||||||
|
registry-accounts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
- name: Install OCI test dependencies
|
||||||
|
run: python3 -m pip install -r oci/requirements.txt
|
||||||
|
- name: Run watchdog identity and lifecycle regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: python3 -m unittest discover -s oci/tests -p test_watchdog.py -v
|
||||||
|
- name: Run OCI image reuse and resource edit regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
|
||||||
|
- name: Run carried record, recovery and configuration cache regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
|
||||||
|
- name: Run read-only volume preparation regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: |
|
||||||
|
test -x /bin/bash
|
||||||
|
test -x /usr/bin/jq
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
|
||||||
|
- name: Run offline registry account tests
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
|
||||||
|
- name: Run OCI menu update checks and selection regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s oci/tests -p 'test_update*.py' -v
|
||||||
|
python3 -m unittest discover -s AppImage/scripts/tests -p 'test_oci*.py' -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_recreate_registry_digest.py -v
|
||||||
|
- name: Run OCI Modify latency regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||||
|
- name: Run OCI modification menu regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
|
||||||
|
- name: Run external OCI settings regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: |
|
||||||
|
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
|
||||||
|
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
|
||||||
|
- name: Run managed volume startup regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: python3 -m unittest discover -s oci/tests -p 'test_managed_volume_*.py' -v
|
||||||
|
- name: Run catalog web-link regressions
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: python3 -m unittest discover -s oci/tests -p test_catalog_web_links.py -v
|
||||||
|
- name: Run full OCI engine test suite
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: python3 -m unittest discover -s oci/tests -v
|
||||||
|
|
||||||
|
pbs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python: ['3.11', '3.13']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
- name: Install test dependencies
|
||||||
|
run: python -m pip install Flask PyJWT cryptography pyotp segno
|
||||||
|
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||||
|
run: |
|
||||||
|
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||||
|
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
|
||||||
|
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||||
|
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||||
|
bash -n "$script"
|
||||||
|
done
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
if: matrix.python == '3.11'
|
||||||
|
with:
|
||||||
|
node-version: '22.14.0'
|
||||||
|
- name: Dashboard platform routing
|
||||||
|
if: matrix.python == '3.11'
|
||||||
|
run: |
|
||||||
|
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||||
|
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
|
||||||
|
|
||||||
|
native-image-import:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Exercise private images with isolated mapped Linux readers
|
||||||
|
run: sudo /usr/bin/python3 -B -m unittest discover -s oci/tests -p test_native_import_access.py -v
|
||||||
|
|
||||||
|
network-topology:
|
||||||
|
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'
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22.14.0'
|
||||||
|
- name: Test read-only OVS discovery and mixed network API
|
||||||
|
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_ovs_topology.py -v
|
||||||
|
- name: Test both Network Flow layouts
|
||||||
|
run: |
|
||||||
|
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||||
|
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/ovs-network-flow.cjs
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Offline i18n tests
|
name: Offline script regressions
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -9,15 +9,20 @@ on:
|
|||||||
- 'AppImage/messages/**'
|
- 'AppImage/messages/**'
|
||||||
- 'AppImage/lib/i18n/**'
|
- 'AppImage/lib/i18n/**'
|
||||||
- 'AppImage/components/terminal-panel.tsx'
|
- 'AppImage/components/terminal-panel.tsx'
|
||||||
|
- 'web/**'
|
||||||
- 'CONTRIBUTING.md'
|
- 'CONTRIBUTING.md'
|
||||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||||
- 'scripts/**/*.sh'
|
- 'scripts/**/*.sh'
|
||||||
- 'scripts/**/*.func'
|
- 'scripts/**/*.func'
|
||||||
- 'scripts/**/*.py'
|
- 'scripts/**/*.py'
|
||||||
- 'lang/*.json'
|
- 'lang/*.json'
|
||||||
|
- 'menu'
|
||||||
|
- 'install_proxmenux.sh'
|
||||||
|
- 'install_proxmenux_beta.sh'
|
||||||
- 'oci/src/**/*.py'
|
- 'oci/src/**/*.py'
|
||||||
- 'oci/catalog/**/*.json'
|
- 'oci/catalog/**/*.json'
|
||||||
- 'oci/remote/**/*.sh'
|
- 'oci/remote/**/*.sh'
|
||||||
|
- 'oci/remote/**/*.py'
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [main, develop]
|
||||||
paths:
|
paths:
|
||||||
@@ -27,21 +32,33 @@ on:
|
|||||||
- 'AppImage/messages/**'
|
- 'AppImage/messages/**'
|
||||||
- 'AppImage/lib/i18n/**'
|
- 'AppImage/lib/i18n/**'
|
||||||
- 'AppImage/components/terminal-panel.tsx'
|
- 'AppImage/components/terminal-panel.tsx'
|
||||||
|
- 'web/**'
|
||||||
- 'CONTRIBUTING.md'
|
- 'CONTRIBUTING.md'
|
||||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||||
- 'scripts/**/*.sh'
|
- 'scripts/**/*.sh'
|
||||||
- 'scripts/**/*.func'
|
- 'scripts/**/*.func'
|
||||||
- 'scripts/**/*.py'
|
- 'scripts/**/*.py'
|
||||||
- 'lang/*.json'
|
- 'lang/*.json'
|
||||||
|
- 'menu'
|
||||||
|
- 'install_proxmenux.sh'
|
||||||
|
- 'install_proxmenux_beta.sh'
|
||||||
- 'oci/src/**/*.py'
|
- 'oci/src/**/*.py'
|
||||||
- 'oci/catalog/**/*.json'
|
- 'oci/catalog/**/*.json'
|
||||||
- 'oci/remote/**/*.sh'
|
- 'oci/remote/**/*.sh'
|
||||||
|
- 'oci/remote/**/*.py'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
# A newer push to the same branch or pull request replaces the run in progress.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Preserve this check ID for existing branch protection rules. This suite
|
||||||
|
# includes menu, download and scheduler regressions as well as i18n tests.
|
||||||
offline-i18n:
|
offline-i18n:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
@@ -59,9 +76,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
test -x /bin/bash
|
test -x /bin/bash
|
||||||
test -x /usr/bin/jq
|
test -x /usr/bin/jq
|
||||||
- name: Install OCI wording test dependency
|
- name: Install OCI wording test dependencies
|
||||||
run: python3 -m pip install 'PyYAML>=6,<7'
|
run: python3 -m pip install 'PyYAML>=6,<7' 'jsonschema==4.25.1'
|
||||||
- name: Run complete offline script test suite
|
- name: Run offline i18n and script regressions
|
||||||
env:
|
env:
|
||||||
PYTHONDONTWRITEBYTECODE: '1'
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
run: python3 -m unittest discover -s .github/scripts/tests -v
|
run: python3 -m unittest discover -s .github/scripts/tests -v
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ on:
|
|||||||
- 'AppImage/package.json'
|
- 'AppImage/package.json'
|
||||||
- 'AppImage/package-lock.json'
|
- 'AppImage/package-lock.json'
|
||||||
- 'AppImage/scripts/*.py'
|
- 'AppImage/scripts/*.py'
|
||||||
|
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||||
|
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||||
|
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||||
|
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||||
|
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||||
|
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||||
|
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||||
- 'AppImage/app/**'
|
- 'AppImage/app/**'
|
||||||
- 'AppImage/components/**'
|
- 'AppImage/components/**'
|
||||||
- 'AppImage/hooks/**'
|
- 'AppImage/hooks/**'
|
||||||
@@ -30,6 +37,13 @@ on:
|
|||||||
- 'AppImage/package.json'
|
- 'AppImage/package.json'
|
||||||
- 'AppImage/package-lock.json'
|
- 'AppImage/package-lock.json'
|
||||||
- 'AppImage/scripts/*.py'
|
- 'AppImage/scripts/*.py'
|
||||||
|
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||||
|
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||||
|
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||||
|
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||||
|
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||||
|
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||||
|
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||||
- 'AppImage/app/**'
|
- 'AppImage/app/**'
|
||||||
- 'AppImage/components/**'
|
- 'AppImage/components/**'
|
||||||
- 'AppImage/hooks/**'
|
- 'AppImage/hooks/**'
|
||||||
@@ -43,6 +57,11 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
# A newer push to the same branch or pull request replaces the run in progress.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
offline-python:
|
offline-python:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -54,10 +73,16 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
- name: Run eight qualified Python files
|
- name: Install native discovery route fixture dependency
|
||||||
|
run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1' 'PyJWT>=2,<3' 'gevent>=25,<27' 'flask-sock==0.7.0' requests
|
||||||
|
- name: Run qualified Python files
|
||||||
env:
|
env:
|
||||||
PYTHONDONTWRITEBYTECODE: '1'
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
|
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
|
||||||
|
- name: Run health recurrence tests without TMPDIR
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: env -u TMPDIR python3 -I -B -m unittest discover -s AppImage/scripts/tests -p test_health_persistence_recurrence.py -v
|
||||||
|
|
||||||
offline-node:
|
offline-node:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -76,7 +101,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||||
ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules
|
ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules
|
||||||
- name: Run eleven qualified Node files
|
- name: Run qualified Node files
|
||||||
env:
|
env:
|
||||||
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
|
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
|
||||||
PYTHONDONTWRITEBYTECODE: '1'
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: USB/IP over SSH tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'scripts/devices/**'
|
||||||
|
- 'scripts/menus/hw_grafics_menu.sh'
|
||||||
|
- 'scripts/menus/main_menu.sh'
|
||||||
|
- 'scripts/menus/search_index.json'
|
||||||
|
- '.github/scripts/tests/test_usbip*.py'
|
||||||
|
- '.github/scripts/check_usbip_transport.py'
|
||||||
|
- '.github/workflows/test-usbip.yml'
|
||||||
|
- 'docs/usbip.md'
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
paths:
|
||||||
|
- 'scripts/devices/**'
|
||||||
|
- '.github/scripts/tests/test_usbip*.py'
|
||||||
|
- '.github/scripts/check_usbip_transport.py'
|
||||||
|
- '.github/workflows/test-usbip.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
usbip:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 8
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python: ['3.11', '3.13']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
- name: Device ownership, reconnect and systemd validation
|
||||||
|
env:
|
||||||
|
PYTHONDONTWRITEBYTECODE: '1'
|
||||||
|
run: python3 -B -m unittest discover -s .github/scripts/tests -p 'test_usbip*.py' -v
|
||||||
|
- name: Install fixture-only transport dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -o APT::Update::Error-Mode=any
|
||||||
|
sudo apt-get install -y socat openssh-client openssh-server iproute2 util-linux
|
||||||
|
- name: Real SSH and private namespace transport, without USB hardware
|
||||||
|
run: sudo "$(command -v python3)" -B .github/scripts/check_usbip_transport.py
|
||||||
@@ -14,6 +14,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- ".github/scripts/generate_app_tracking_catalog.py"
|
- ".github/scripts/generate_app_tracking_catalog.py"
|
||||||
|
- ".github/scripts/tests/test_app_tracking_sources.py"
|
||||||
- ".github/workflows/update-app-tracking-hints.yml"
|
- ".github/workflows/update-app-tracking-hints.yml"
|
||||||
- "json/runtime_verified_overrides.json"
|
- "json/runtime_verified_overrides.json"
|
||||||
|
|
||||||
@@ -37,6 +38,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Test Helper-Scripts source layouts
|
||||||
|
run: python -B -m unittest discover -s .github/scripts/tests -p test_app_tracking_sources.py -v
|
||||||
|
|
||||||
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
|
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
.cache
|
.cache
|
||||||
|
/oci/.catalog-cache/
|
||||||
/web/.cache
|
/web/.cache
|
||||||
|
|
||||||
# IDE/editor files
|
# IDE/editor files
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
07f017589011a806e75a0761c99c5ba2450526c601e8860dd31a76252a89f648 ProxMenux-1.2.6.2-beta.AppImage
|
69ed639710aba5804e3b8f33383357f3bcc981b94a5e9b272d214f31e2e87563 ProxMenux-1.2.8.AppImage
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ExternalLink } from "lucide-react"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
// These are examples, never defaults: opening help must not replace a saved
|
||||||
|
// destination or put placeholder credentials into the notification settings.
|
||||||
|
export function AppriseServiceHelp() {
|
||||||
|
const t = useT()
|
||||||
|
return <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
|
||||||
|
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
|
||||||
|
<div className="grid gap-3 pt-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5 min-w-0">
|
||||||
|
<h4 className="font-medium">Slack</h4>
|
||||||
|
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
|
||||||
|
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
|
||||||
|
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||||
|
{t("settings.notifications.ui.appriseSlackDocs")} <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 min-w-0">
|
||||||
|
<h4 className="font-medium">Matrix</h4>
|
||||||
|
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
|
||||||
|
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
|
||||||
|
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||||
|
{t("settings.notifications.ui.appriseMatrixDocs")} <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ function undoKey(change: { revert: string; exactness: string }): string {
|
|||||||
|
|
||||||
const FN_LABEL: Record<string, string> = {
|
const FN_LABEL: Record<string, string> = {
|
||||||
// Internal helpers of a post-install feature show its menu name, not their
|
// Internal helpers of a post-install feature show its menu name, not their
|
||||||
// raw name — and never the source ("auto"/"customizable").
|
// raw name — and never the source ("auto"/"custom").
|
||||||
_update_existing_log2ram_auto: "Install and configure Log2RAM",
|
_update_existing_log2ram_auto: "Install and configure Log2RAM",
|
||||||
_update_existing_log2ram_custom: "Install and configure Log2RAM",
|
_update_existing_log2ram_custom: "Install and configure Log2RAM",
|
||||||
update_snapshot_schedule: "Install ZFS auto-snapshot",
|
update_snapshot_schedule: "Install ZFS auto-snapshot",
|
||||||
@@ -130,9 +130,10 @@ const FN_LABEL: Record<string, string> = {
|
|||||||
update_pve_appliance_manager: "Update Proxmox VE Appliance Manager",
|
update_pve_appliance_manager: "Update Proxmox VE Appliance Manager",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-install functions run from the auto/customizable scripts; everything
|
// Post-install functions run from the automatic and the customizable scripts,
|
||||||
// else is a general host script (nvidia/tpu installers, PVE update, vfio…).
|
// which record themselves as "auto" and "custom"; everything else is a general
|
||||||
const POST_INSTALL_SOURCES = new Set(["auto", "customizable"])
|
// host script (nvidia/tpu installers, PVE update, vfio…).
|
||||||
|
const POST_INSTALL_SOURCES = new Set(["auto", "custom"])
|
||||||
|
|
||||||
// A few sources deserve a friendly name instead of a raw script identifier;
|
// A few sources deserve a friendly name instead of a raw script identifier;
|
||||||
// everything else shows the script it came from.
|
// everything else shows the script it came from.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import { HardDrive, Loader2, Pencil, RefreshCw } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||||
|
import { Badge } from "./ui/badge"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { Checkbox } from "./ui/checkbox"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
|
import { Input } from "./ui/input"
|
||||||
|
import { Label } from "./ui/label"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { buildRetention, parseRetention, RETENTION } from "../lib/backup-retention"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
interface StorageRetention { id: string; type: string; prune_backups: string; digest: string }
|
||||||
|
const fetcher = (url: string) => fetchApi<{ storages: StorageRetention[] }>(url)
|
||||||
|
|
||||||
|
export function BackupStorageRetention() {
|
||||||
|
const t = useT()
|
||||||
|
const { data, error, mutate, isValidating } = useSWR("/api/vm-backup-storages", fetcher)
|
||||||
|
const [editing, setEditing] = useState<StorageRetention | null>(null)
|
||||||
|
const [fields, setFields] = useState<Record<string, string>>({})
|
||||||
|
const [keepAll, setKeepAll] = useState(false)
|
||||||
|
const [useDefault, setUseDefault] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
const [saveError, setSaveError] = useState("")
|
||||||
|
|
||||||
|
function edit(storage: StorageRetention) {
|
||||||
|
const policy = parseRetention(storage.prune_backups)
|
||||||
|
setFields(policy.fields)
|
||||||
|
setKeepAll(policy.keepAll)
|
||||||
|
setUseDefault(!storage.prune_backups)
|
||||||
|
setSaveError("")
|
||||||
|
setMessage("")
|
||||||
|
setEditing(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editing || busy) return
|
||||||
|
const prune = useDefault ? "" : buildRetention(fields, keepAll)
|
||||||
|
if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy"))
|
||||||
|
setBusy(true)
|
||||||
|
setSaveError("")
|
||||||
|
try {
|
||||||
|
await fetchApi(`/api/vm-backup-storages/${encodeURIComponent(editing.id)}`, {
|
||||||
|
method: "PUT", body: JSON.stringify({ digest: editing.digest, prune_backups: prune }),
|
||||||
|
})
|
||||||
|
setMessage(t("backup.storageRetention.saved"))
|
||||||
|
setEditing(null)
|
||||||
|
await mutate()
|
||||||
|
} catch (error) {
|
||||||
|
setSaveError(`${t("backup.storageRetention.saveFailed")} ${error instanceof Error ? error.message : ""}`)
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Card className="border-border bg-card/50">
|
||||||
|
<CardHeader className="pb-3 flex-row items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2"><HardDrive className="h-4 w-4" />{t("backup.storageRetention.title")}</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" disabled={isValidating} onClick={() => { void mutate().catch(() => {}) }}>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${isValidating ? "animate-spin" : ""}`} />{t("backup.storageRetention.refresh")}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.description")}</p>
|
||||||
|
{message && <p role="status" className="text-xs text-emerald-500">{message}</p>}
|
||||||
|
{error ? <p role="alert" className="text-xs text-destructive">{t("backup.storageRetention.loadFailed")}</p> : !data ?
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> : !data.storages.length ?
|
||||||
|
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.empty")}</p> :
|
||||||
|
data.storages.map(storage => <div key={storage.id} className="flex flex-wrap gap-2 items-center border rounded-lg p-3">
|
||||||
|
<span className="font-medium text-sm">{storage.id}</span><Badge variant="outline">{storage.type}</Badge>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground break-all">{storage.prune_backups || t("backup.storageRetention.default")}</span>
|
||||||
|
<Button variant="outline" size="sm" className="ml-auto gap-1" onClick={() => edit(storage)} disabled={!storage.digest || busy}>
|
||||||
|
<Pencil className="h-3 w-3" />{t("backup.storageRetention.edit")}
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
|
</CardContent>
|
||||||
|
<Dialog open={!!editing} onOpenChange={open => { if (!open && !busy) setEditing(null) }}>
|
||||||
|
<DialogContent className="bg-accent [&_input]:bg-background">
|
||||||
|
<DialogHeader><DialogTitle>{t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })}</DialogTitle>
|
||||||
|
<DialogDescription>{t("backup.storageRetention.effect")}</DialogDescription></DialogHeader>
|
||||||
|
<Label className="flex items-center gap-2"><Checkbox checked={useDefault} disabled={busy} onCheckedChange={checked => {
|
||||||
|
setUseDefault(checked === true)
|
||||||
|
if (checked) { setKeepAll(false); setFields({}) }
|
||||||
|
}} />{t("backup.storageRetention.useDefault")}</Label>
|
||||||
|
<Label className="flex items-center gap-2"><Checkbox checked={keepAll} disabled={useDefault || busy} onCheckedChange={checked => {
|
||||||
|
setKeepAll(checked === true)
|
||||||
|
if (checked) setFields({})
|
||||||
|
}} />{t("backup.storageRetention.keepAll")}</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{RETENTION.map(key => <div key={key} className="space-y-1">
|
||||||
|
<Label htmlFor={`storage-retention-${key}`}>{t(`backup.retention.keep${key[0].toUpperCase()}${key.slice(1)}`)}</Label>
|
||||||
|
<Input id={`storage-retention-${key}`} type="number" min={1} max={99999} step={1} disabled={useDefault || keepAll || busy}
|
||||||
|
value={fields[key] ?? ""} onChange={event => setFields(previous => ({ ...previous, [key]: event.target.value }))} />
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
{saveError && <p role="alert" className="text-xs text-destructive break-words">{saveError}</p>}
|
||||||
|
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>{t("backup.storageRetention.cancel")}</Button>
|
||||||
|
<Button disabled={busy} onClick={save}>{busy && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}{t("backup.storageRetention.save")}</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import { useState } from "react"
|
|||||||
import { DatabaseBackup } from "lucide-react"
|
import { DatabaseBackup } from "lucide-react"
|
||||||
import { HostBackup } from "./host-backup"
|
import { HostBackup } from "./host-backup"
|
||||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||||
|
import { PBSPanel } from "./pbs-panel"
|
||||||
|
import { BackupStorageRetention } from "./backup-storage-retention"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
type BackupView = "host" | "guests"
|
type BackupView = "host" | "guests" | "pbs"
|
||||||
|
|
||||||
export function BackupTab() {
|
export function BackupTab() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
@@ -29,7 +31,7 @@ export function BackupTab() {
|
|||||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||||
sm:inline-flex sm:w-auto"
|
sm:inline-flex sm:w-auto"
|
||||||
>
|
>
|
||||||
{(["host", "guests"] as const).map((key) => (
|
{(["host", "guests", "pbs"] as const).map((key) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -41,7 +43,7 @@ export function BackupTab() {
|
|||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`backup.viewSwitch.${key}`)}
|
{key === "pbs" ? t("pbs.title") : t(`backup.viewSwitch.${key}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +53,8 @@ export function BackupTab() {
|
|||||||
<div className={view === "host" ? "" : "hidden"}>
|
<div className={view === "host" ? "" : "hidden"}>
|
||||||
<HostBackup />
|
<HostBackup />
|
||||||
</div>
|
</div>
|
||||||
{view === "guests" && <VmBackupJobs />}
|
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
|
||||||
|
{view === "pbs" && <PBSPanel />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface DiskEntry {
|
|||||||
rotational: boolean
|
rotational: boolean
|
||||||
excluded: boolean
|
excluded: boolean
|
||||||
excluded_at: string | null
|
excluded_at: string | null
|
||||||
idle: boolean
|
|
||||||
present: boolean
|
present: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,15 +205,6 @@ export function DiskExclusions() {
|
|||||||
{t("settings.diskExclusions.excluded")}
|
{t("settings.diskExclusions.excluded")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!excluded && disk.idle && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground"
|
|
||||||
title={t("storage.idleTitle")}
|
|
||||||
>
|
|
||||||
{t("storage.idle")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!disk.present && (
|
{!disk.present && (
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
|
||||||
{t("settings.diskExclusions.notConnected")}
|
{t("settings.diskExclusions.notConnected")}
|
||||||
@@ -252,8 +242,6 @@ export function DiskExclusions() {
|
|||||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
{t("settings.diskExclusions.help")}
|
{t("settings.diskExclusions.help")}
|
||||||
<br />
|
|
||||||
{t("settings.diskExclusions.idleHelp")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Archive, Clock, Loader2, LockKeyhole, RefreshCw, Trash2 } from "lucide-react"
|
||||||
|
import { Badge } from "./ui/badge"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { Checkbox } from "./ui/checkbox"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives"
|
||||||
|
|
||||||
|
export function GuestBackupList({ vmid, backups, loading, onRefresh, storageColor }: {
|
||||||
|
vmid: number
|
||||||
|
backups: GuestBackup[]
|
||||||
|
loading: boolean
|
||||||
|
onRefresh: () => Promise<void>
|
||||||
|
storageColor?: (storage: string) => { bg: string; text: string; border: string }
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
|
const [confirm, setConfirm] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const mounted = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true
|
||||||
|
return () => { mounted.current = false }
|
||||||
|
}, [])
|
||||||
|
// A fresh list can remove a selection or reveal a newly protected archive.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(previous => previous.filter(volid => backups.some(backup => backup.volid === volid && !backup.protected)))
|
||||||
|
}, [backups])
|
||||||
|
|
||||||
|
const chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected)
|
||||||
|
const selectable = backups.filter(backup => !backup.protected)
|
||||||
|
const disabled = loading || busy || refreshing
|
||||||
|
const storageClass = (storage: string) => {
|
||||||
|
const color = storageColor?.(storage)
|
||||||
|
return color ? `${color.bg} ${color.text} ${color.border}` : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setRefreshing(true)
|
||||||
|
setError("")
|
||||||
|
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
|
||||||
|
finally { if (mounted.current) setRefreshing(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!chosen.length || busy) return
|
||||||
|
setBusy(true)
|
||||||
|
setError("")
|
||||||
|
setMessage("")
|
||||||
|
try {
|
||||||
|
const results = await deleteGuestBackups(vmid, chosen)
|
||||||
|
if (!mounted.current) return
|
||||||
|
const deleted = results.filter(result => result.status === "deleted").length
|
||||||
|
const unconfirmed = results.length - deleted
|
||||||
|
setMessage(t("vmLxc.backups.deletedCount", { count: deleted }))
|
||||||
|
if (unconfirmed) setError(t("vmLxc.backups.unconfirmedCount", { count: unconfirmed }))
|
||||||
|
setSelected([])
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted.current) setError(`${t("vmLxc.backups.deleteFailed")} ${error instanceof Error ? error.message : ""}`)
|
||||||
|
} finally {
|
||||||
|
// A timeout can hide a successful native task. Always bypass both caches.
|
||||||
|
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
|
||||||
|
if (mounted.current) { setBusy(false); setConfirm(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3 shrink-0 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs h-5">{backups.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" disabled={disabled} onClick={refresh}>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{t("vmLxc.backups.refresh")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" disabled={disabled || !chosen.length} onClick={() => setConfirm(true)}>
|
||||||
|
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||||
|
{t("vmLxc.backups.deleteSelected", { count: chosen.length })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{message && <p role="status" className="text-xs text-muted-foreground mb-2">{message}</p>}
|
||||||
|
{error && <p role="alert" className="text-xs text-destructive mb-2 break-words">{error}</p>}
|
||||||
|
{loading ? <div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" /><span className="text-sm">{t("vmLxc.backups.loading")}</span>
|
||||||
|
</div> : backups.length === 0 ? <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
|
||||||
|
<Archive className="h-12 w-12 mb-3 opacity-30" /><span className="text-sm">{t("vmLxc.backups.empty")}</span>
|
||||||
|
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
|
||||||
|
</div> : <>
|
||||||
|
<label className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
|
||||||
|
<Checkbox checked={selectable.length > 0 && chosen.length === Math.min(20, selectable.length)} disabled={disabled || !selectable.length}
|
||||||
|
onCheckedChange={checked => setSelected(checked ? selectable.slice(0, 20).map(backup => backup.volid) : [])} />
|
||||||
|
{t("vmLxc.backups.selectHint")}
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
|
||||||
|
{backups.map(backup => <label key={backup.volid} className="flex items-center gap-2 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||||
|
<Checkbox aria-label={`${t("vmLxc.backups.selectBackup")} ${backup.date} ${backup.storage}`} checked={selected.includes(backup.volid)}
|
||||||
|
disabled={disabled || !!backup.protected || (chosen.length >= 20 && !selected.includes(backup.volid))}
|
||||||
|
onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm min-w-0 truncate" title={backup.volid}>{backup.date}</span>
|
||||||
|
{backup.protected && <Badge variant="secondary" className="gap-1 text-xs"><LockKeyhole className="h-3 w-3" />{t("vmLxc.backups.protected")}</Badge>}
|
||||||
|
<Badge variant="outline" className={`text-xs ml-auto shrink-0 ${storageClass(backup.storage)}`}>{backup.storage}</Badge>
|
||||||
|
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
|
||||||
|
</label>)}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
<Dialog open={confirm} onOpenChange={value => { if (!busy) setConfirm(value) }}>
|
||||||
|
<DialogContent onEscapeKeyDown={event => { if (busy) event.preventDefault() }} onPointerDownOutside={event => { if (busy) event.preventDefault() }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("vmLxc.backups.confirmTitle", { count: chosen.length })}</DialogTitle>
|
||||||
|
<DialogDescription>{t("vmLxc.backups.confirmDescription", { vmid })}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ul className="max-h-48 overflow-auto text-xs space-y-2 break-all">
|
||||||
|
{chosen.map(backup => <li key={backup.volid}>{backup.volid}</li>)}
|
||||||
|
</ul>
|
||||||
|
{busy && <p role="status" className="text-sm text-muted-foreground">{t("vmLxc.backups.deleting")}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => setConfirm(false)}>{t("vmLxc.backups.cancel")}</Button>
|
||||||
|
<Button variant="destructive" disabled={busy || !chosen.length} onClick={remove}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}{t("vmLxc.backups.confirmDelete")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -633,6 +633,13 @@ export default function Hardware() {
|
|||||||
return realtimeGPUData.has_monitoring_tool === true
|
return realtimeGPUData.has_monitoring_tool === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const diskCountBySlot = new Map<string, number>()
|
||||||
|
for (const disk of hardwareData?.storage_devices ?? []) {
|
||||||
|
if (disk.controller_slot) {
|
||||||
|
diskCountBySlot.set(disk.controller_slot, (diskCountBySlot.get(disk.controller_slot) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||||
@@ -2358,14 +2365,23 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{hardwareData.pci_devices.map((device, index) => (
|
{hardwareData.pci_devices.map((device, index) => {
|
||||||
|
const diskCount = diskCountBySlot.get(device.slot) ?? 0
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setSelectedPCIDevice(device)}
|
onClick={() => setSelectedPCIDevice(device)}
|
||||||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 mb-2">
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
|
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
|
||||||
|
{diskCount > 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{t("hardware.counts.devices", { count: diskCount })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
|
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
|
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
|
||||||
@@ -2374,7 +2390,8 @@ return (
|
|||||||
<p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
|
<p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -2434,6 +2451,37 @@ return (
|
|||||||
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
|
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const attachedDisks = (hardwareData?.storage_devices ?? []).filter(
|
||||||
|
(disk) => disk.controller_slot === selectedPCIDevice.slot,
|
||||||
|
)
|
||||||
|
if (attachedDisks.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("hardware.labels.attachedDisks")} ({attachedDisks.length})
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap justify-end gap-1">
|
||||||
|
{attachedDisks.map((disk) => (
|
||||||
|
<button
|
||||||
|
key={disk.name}
|
||||||
|
type="button"
|
||||||
|
title={disk.model}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPCIDevice(null)
|
||||||
|
setSelectedDisk(disk)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant="outline" className="font-mono hover:bg-accent">
|
||||||
|
{disk.name}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -2827,6 +2875,34 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedDisk.controller_slot && (() => {
|
||||||
|
// Fall back to the bare slot when the controller's PCI class isn't one we list
|
||||||
|
const controller = hardwareData?.pci_devices?.find((d) => d.slot === selectedDisk.controller_slot)
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.storageController")}</span>
|
||||||
|
<span className="text-sm text-right">
|
||||||
|
{controller ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-blue-400 hover:underline text-right"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDisk(null)
|
||||||
|
setSelectedPCIDevice(controller)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* lspci's "Device 84ca" placeholder means no subsystem name, so use the chip name */}
|
||||||
|
{controller.sdevice && !/^Device [0-9a-f]{4}$/.test(controller.sdevice)
|
||||||
|
? controller.sdevice
|
||||||
|
: controller.device}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-2 font-mono text-muted-foreground">{selectedDisk.controller_slot}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
|
{selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
|
||||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.rotationRate")}</span>
|
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.rotationRate")}</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback, useRef } from "react"
|
||||||
import { getAuthToken } from "@/lib/api-config"
|
import { getAuthToken } from "@/lib/api-config"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -41,7 +41,11 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
|
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
|
||||||
|
import { HostUpdateResult } from "./host-update-result"
|
||||||
|
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
|
||||||
import { useT } from "@/lib/i18n/provider"
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
import { useIsPbs } from "@/lib/use-platform"
|
||||||
|
|
||||||
interface CategoryCheck {
|
interface CategoryCheck {
|
||||||
status: string
|
status: string
|
||||||
@@ -119,17 +123,29 @@ const CATEGORIES = [
|
|||||||
|
|
||||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
|
const isPbs = useIsPbs()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||||
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
|
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [snapshotUnavailable, setSnapshotUnavailable] = useState(false)
|
||||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||||
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
||||||
|
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
|
||||||
|
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
|
||||||
|
const [resultJobId, setResultJobId] = useState<string | null>(null)
|
||||||
|
const healthRequest = useRef<AbortController | null>(null)
|
||||||
|
const previousAutoExpanded = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
const fetchHealthDetails = useCallback(async (force = false) => {
|
const fetchHealthDetails = useCallback(async (force = false, cachedOnly = false) => {
|
||||||
setLoading(true)
|
if (cachedOnly && healthRequest.current) return
|
||||||
|
healthRequest.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
healthRequest.current = controller
|
||||||
|
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
|
||||||
|
if (!cachedOnly) setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -142,14 +158,26 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
authHeaders["Authorization"] = `Bearer ${token}`
|
authHeaders["Authorization"] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders })
|
const path = cachedOnly ? "/api/health/full?cached=1" : force ? "/api/health/full?refresh=1" : "/api/health/full"
|
||||||
|
const response = await fetch(getApiUrl(path), { headers: authHeaders, signal: controller.signal })
|
||||||
|
if (healthRequest.current !== controller) return
|
||||||
let infoCount = 0
|
let infoCount = 0
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (cachedOnly && response.status === 503) {
|
||||||
|
// Completion invalidates the snapshot before the collector refills it.
|
||||||
|
// Keep the dated result, explicitly unavailable; never start an audit.
|
||||||
|
setSnapshotUnavailable(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Polling cannot fall back to a legacy endpoint that runs a full audit.
|
||||||
|
if (cachedOnly) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||||
// Fallback to legacy endpoint
|
// Fallback to legacy endpoint
|
||||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders, signal: controller.signal })
|
||||||
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
|
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||||
const data = await legacyResponse.json()
|
const data = await legacyResponse.json()
|
||||||
|
if (healthRequest.current !== controller) return
|
||||||
|
setSnapshotUnavailable(false)
|
||||||
setHealthData(data)
|
setHealthData(data)
|
||||||
setDismissedItems([])
|
setDismissedItems([])
|
||||||
setCustomSuppressions([])
|
setCustomSuppressions([])
|
||||||
@@ -166,6 +194,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const fullData: FullHealthData = await response.json()
|
const fullData: FullHealthData = await response.json()
|
||||||
|
if (healthRequest.current !== controller) return
|
||||||
|
if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||||
|
setSnapshotUnavailable(false)
|
||||||
setHealthData(fullData.health)
|
setHealthData(fullData.health)
|
||||||
setDismissedItems(fullData.dismissed || [])
|
setDismissedItems(fullData.dismissed || [])
|
||||||
setCustomSuppressions(fullData.custom_suppressions || [])
|
setCustomSuppressions(fullData.custom_suppressions || [])
|
||||||
@@ -205,10 +236,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
})
|
})
|
||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (healthRequest.current === controller) {
|
||||||
|
setSnapshotUnavailable(false)
|
||||||
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (timeout !== undefined) clearTimeout(timeout)
|
||||||
|
if (healthRequest.current === controller) {
|
||||||
|
healthRequest.current = null
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [getApiUrl, t])
|
}, [getApiUrl, t])
|
||||||
|
|
||||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||||
@@ -223,9 +261,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
fetchHealthDetails()
|
fetchHealthDetails()
|
||||||
// Auto-refresh every 5 minutes while modal is open
|
// Recheck reboot evidence promptly without running APT/full health checks.
|
||||||
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
|
const refresh = () => {
|
||||||
return () => clearInterval(refreshInterval)
|
if (document.visibilityState === "visible") void fetchHealthDetails(false, true)
|
||||||
|
}
|
||||||
|
const refreshInterval = setInterval(refresh, 30000)
|
||||||
|
window.addEventListener("focus", refresh)
|
||||||
|
document.addEventListener("visibilitychange", refresh)
|
||||||
|
return () => {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
window.removeEventListener("focus", refresh)
|
||||||
|
document.removeEventListener("visibilitychange", refresh)
|
||||||
|
const controller = healthRequest.current
|
||||||
|
healthRequest.current = null
|
||||||
|
controller?.abort()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [open, fetchHealthDetails])
|
}, [open, fetchHealthDetails])
|
||||||
|
|
||||||
@@ -243,7 +293,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
nonOkCategories.add(key)
|
nonOkCategories.add(key)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setExpandedCategories(nonOkCategories)
|
const newlyDegraded = [...nonOkCategories].filter(key => !previousAutoExpanded.current.has(key))
|
||||||
|
previousAutoExpanded.current = nonOkCategories
|
||||||
|
if (newlyDegraded.length) {
|
||||||
|
setExpandedCategories(previous => new Set([...previous, ...newlyDegraded]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [healthData])
|
}, [healthData])
|
||||||
|
|
||||||
@@ -298,6 +352,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
|
|
||||||
const translateHealthText = (value?: string): string => {
|
const translateHealthText = (value?: string): string => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
|
// Health summaries include the category before the same detail text.
|
||||||
|
if (value.startsWith("Updates: ")) {
|
||||||
|
return `${t("healthStatus.categories.updates")}: ${translateHealthText(value.slice(9))}`
|
||||||
|
}
|
||||||
const exact: Record<string, string> = {
|
const exact: Record<string, string> = {
|
||||||
"No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"),
|
"No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"),
|
||||||
"Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"),
|
"Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"),
|
||||||
@@ -328,6 +386,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
"Active": t("healthStatus.details.active"),
|
"Active": t("healthStatus.details.active"),
|
||||||
"UP": t("healthStatus.details.up"),
|
"UP": t("healthStatus.details.up"),
|
||||||
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
|
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
|
||||||
|
"No reboot required": t("healthStatus.hostUpdate.noReboot"),
|
||||||
|
"No pending updates": t("healthStatus.details.noPendingUpdates"),
|
||||||
|
"Reboot required by the operating system": t("healthStatus.hostUpdate.rebootMarker"),
|
||||||
|
"Could not determine whether a reboot is required": t("healthStatus.hostUpdate.rebootUnknown"),
|
||||||
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
|
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
|
||||||
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
|
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
|
||||||
"No container startup errors": t("healthStatus.details.noContainerErrors"),
|
"No container startup errors": t("healthStatus.details.noContainerErrors"),
|
||||||
@@ -339,6 +401,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
|
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
|
||||||
|
|
||||||
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
|
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
|
||||||
|
const reboot = value.match(/^Reboot required: running (.+); installed (.+)$/)
|
||||||
|
if (reboot) return t("healthStatus.hostUpdate.rebootKernel", { running: reboot[1], installed: reboot[2] })
|
||||||
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
|
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
|
||||||
match = value.match(/^(\d+) failed login attempts in 24h$/)
|
match = value.match(/^(\d+) failed login attempts in 24h$/)
|
||||||
if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
|
if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
|
||||||
@@ -460,6 +524,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
return originalStatus?.toUpperCase() || "UNKNOWN"
|
return originalStatus?.toUpperCase() || "UNKNOWN"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checks the host reports as not applicable (guests on PBS) are not listed or counted.
|
||||||
|
const visibleCategories = CATEGORIES.filter(({ key }) =>
|
||||||
|
!(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable)
|
||||||
|
const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`)
|
||||||
|
|
||||||
const getHealthStats = () => {
|
const getHealthStats = () => {
|
||||||
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
|
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
|
||||||
|
|
||||||
@@ -469,7 +538,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
let critical = 0
|
let critical = 0
|
||||||
let unknown = 0
|
let unknown = 0
|
||||||
|
|
||||||
CATEGORIES.forEach(({ key }) => {
|
visibleCategories.forEach(({ key }) => {
|
||||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||||
if (categoryData) {
|
if (categoryData) {
|
||||||
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
|
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
|
||||||
@@ -481,7 +550,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
|
return { total: visibleCategories.length, healthy, info, warnings, critical, unknown }
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = getHealthStats()
|
const stats = getHealthStats()
|
||||||
@@ -616,7 +685,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
|
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
|
||||||
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
||||||
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
|
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
|
||||||
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
|
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", "reboot_required",
|
||||||
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
|
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
|
||||||
])
|
])
|
||||||
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
|
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
|
||||||
@@ -627,6 +696,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const translateHealthSummary = (snapshot: HealthDetails): string => {
|
||||||
|
if (snapshot.summary === "Updates") {
|
||||||
|
const reboot = snapshot.details?.updates?.checks?.reboot_required
|
||||||
|
if (reboot?.status === "INFO" && typeof reboot.detail === "string" && reboot.detail) {
|
||||||
|
return translateHealthText(reboot.detail)
|
||||||
|
}
|
||||||
|
return t("healthStatus.categories.updates")
|
||||||
|
}
|
||||||
|
return translateHealthText(snapshot.summary)
|
||||||
|
}
|
||||||
|
|
||||||
const renderChecks = (
|
const renderChecks = (
|
||||||
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
|
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
|
||||||
categoryKey: string
|
categoryKey: string
|
||||||
@@ -638,6 +718,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
{Object.entries(checks)
|
{Object.entries(checks)
|
||||||
.filter(([, checkData]) => checkData.installed !== false)
|
.filter(([, checkData]) => checkData.installed !== false)
|
||||||
.map(([checkKey, checkData]) => {
|
.map(([checkKey, checkData]) => {
|
||||||
|
if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") {
|
||||||
|
return <RestartAfterUpdate key={checkKey} getApiUrl={getApiUrl}
|
||||||
|
unavailable={loading || snapshotUnavailable || !!error}
|
||||||
|
disabled={hostJobs.some(job => job.state !== "complete")} label={formatCheckLabel(checkKey)}
|
||||||
|
detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} />
|
||||||
|
}
|
||||||
const isDismissable = checkData.dismissable === true
|
const isDismissable = checkData.dismissable === true
|
||||||
const checkStatus = checkData.status?.toUpperCase() || "OK"
|
const checkStatus = checkData.status?.toUpperCase() || "OK"
|
||||||
|
|
||||||
@@ -704,6 +790,14 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
|
||||||
|
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
|
||||||
|
onResume={job => {
|
||||||
|
if (job.state === "complete") { setResultJobId(job.id); return }
|
||||||
|
setHostJob({ runId: job.id, resume: true, kind: "host" })
|
||||||
|
setShowUpdateTerminal(true)
|
||||||
|
}} />
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
@@ -717,8 +811,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{healthData && !loading && (
|
{snapshotUnavailable && (
|
||||||
<div className="space-y-4">
|
<div role="status" className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<p>{t("healthStatus.snapshotUnavailable")}</p>
|
||||||
|
{healthData && <p className="mt-1">{t("healthStatus.lastKnownResults")}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{healthData && (
|
||||||
|
<fieldset disabled={loading} className="space-y-4 min-w-0">
|
||||||
{/* Overall Stats Summary */}
|
{/* Overall Stats Summary */}
|
||||||
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
|
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -753,13 +854,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
|
|
||||||
{healthData.summary && healthData.summary !== "All systems operational" && (
|
{healthData.summary && healthData.summary !== "All systems operational" && (
|
||||||
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
||||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
|
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthSummary(healthData)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Category List */}
|
{/* Category List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{CATEGORIES.map(({ key, Icon }) => {
|
{visibleCategories.map(({ key, Icon }) => {
|
||||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||||
const originalStatus = categoryData?.status || "UNKNOWN"
|
const originalStatus = categoryData?.status || "UNKNOWN"
|
||||||
const status = getEffectiveStatus(key, originalStatus)
|
const status = getEffectiveStatus(key, originalStatus)
|
||||||
@@ -784,7 +885,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
|
<p className="font-medium text-xs sm:text-sm truncate">{categoryLabel(key)}</p>
|
||||||
{hasChecks && (
|
{hasChecks && (
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||||
@@ -838,18 +939,30 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
pending prevents the operator from spawning a
|
pending prevents the operator from spawning a
|
||||||
terminal that would only report "System is
|
terminal that would only report "System is
|
||||||
already up to date". */}
|
already up to date". */}
|
||||||
{key === "updates" && status?.toUpperCase() !== "OK" && (
|
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
|
||||||
<div className="flex justify-end px-3 py-2 pt-1">
|
<p className="px-2 sm:px-3 mt-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||||
|
{t("healthStatus.packagePlanNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
|
||||||
|
<div className="flex justify-end px-2 sm:px-3 pt-3 pb-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowUpdateTerminal(true)}
|
disabled={hostJobs.some(job => job.state !== "complete")}
|
||||||
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
onClick={() => {
|
||||||
|
const runId = window.crypto?.randomUUID?.()
|
||||||
|
|| `host-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||||
|
setHostJob({ runId, resume: false, kind: "host" })
|
||||||
|
setShowUpdateTerminal(true)
|
||||||
|
}}
|
||||||
|
className="h-10 sm:h-[34px] bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||||
>
|
>
|
||||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||||
{t("healthStatus.updateNow")}
|
{t("healthStatus.updateSystem")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{key === "updates" && <ProxMenuxVersionCard getApiUrl={getApiUrl} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -871,7 +984,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
{filteredDismissed.map((item) => {
|
{filteredDismissed.map((item) => {
|
||||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||||
const CatIcon = catMeta?.Icon || BellOff
|
const CatIcon = catMeta?.Icon || BellOff
|
||||||
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
|
const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category
|
||||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -936,7 +1049,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
|
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? categoryLabel(catMeta.key) : cs.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||||
{durationLabel}
|
{durationLabel}
|
||||||
@@ -957,11 +1070,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
|
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
|
||||||
<ScriptTerminalModal
|
<ScriptTerminalModal
|
||||||
|
key={hostJob?.runId || "host-update"}
|
||||||
open={showUpdateTerminal}
|
open={showUpdateTerminal}
|
||||||
|
backgroundUpdate={hostJob || undefined}
|
||||||
|
cancelledExitCode={130}
|
||||||
|
onComplete={() => { void fetchHealthDetails(true) }}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowUpdateTerminal(false)
|
setShowUpdateTerminal(false)
|
||||||
// Force a fresh read (cache-busting via ?refresh=1) so the
|
// Force a fresh read (cache-busting via ?refresh=1) so the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { useIsPbs } from "../lib/use-platform"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { Input } from "./ui/input"
|
import { Input } from "./ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Database,
|
Database,
|
||||||
Waves,
|
Waves,
|
||||||
|
Monitor,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
@@ -84,6 +86,7 @@ interface ThresholdsTree {
|
|||||||
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
|
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
|
||||||
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
|
vm_disk: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
disk_temperature: {
|
disk_temperature: {
|
||||||
hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
@@ -201,6 +204,17 @@ const SECTIONS: SectionDef[] = [
|
|||||||
{ path: ["lxc_rootfs", "critical"], label: "Critical" },
|
{ path: ["lxc_rootfs", "critical"], label: "Critical" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "vm_disk",
|
||||||
|
title: "Disk space — VMs",
|
||||||
|
icon: Monitor,
|
||||||
|
description:
|
||||||
|
"Filesystems of running VMs as the QEMU guest agent reports them, passthrough drives included. VMs without the guest agent are not evaluated.",
|
||||||
|
fields: [
|
||||||
|
{ path: ["vm_disk", "warning"], label: "Warning" },
|
||||||
|
{ path: ["vm_disk", "critical"], label: "Critical" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "lxc_mount",
|
id: "lxc_mount",
|
||||||
title: "LXC mount points",
|
title: "LXC mount points",
|
||||||
@@ -283,8 +297,12 @@ function computeVisualRange(
|
|||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Guests and PVE storages do not exist on a Proxmox Backup Server.
|
||||||
|
const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "vm_disk", "lxc_mount", "pve_storage"])
|
||||||
|
|
||||||
export function HealthThresholds() {
|
export function HealthThresholds() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
|
const isPbs = useIsPbs()
|
||||||
const tFallback = (key: string, fallback: string) => {
|
const tFallback = (key: string, fallback: string) => {
|
||||||
const translated = t(key)
|
const translated = t(key)
|
||||||
return translated === key ? fallback : translated
|
return translated === key ? fallback : translated
|
||||||
@@ -773,7 +791,7 @@ export function HealthThresholds() {
|
|||||||
Mobile (<md) stays single-column as today.
|
Mobile (<md) stays single-column as today.
|
||||||
*/}
|
*/}
|
||||||
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
|
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
|
||||||
{SECTIONS.map((section) => {
|
{SECTIONS.filter((section) => !isPbs || !PVE_ONLY_SECTIONS.has(section.id)).map((section) => {
|
||||||
const Icon = section.icon
|
const Icon = section.icon
|
||||||
return (
|
return (
|
||||||
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
|
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
import { RestoreProgressCard } from "./restore-progress-card"
|
import { RestoreProgressCard } from "./restore-progress-card"
|
||||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||||
|
import { hostBackupErrorText } from "../lib/host-backup-messages"
|
||||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||||
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
||||||
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
||||||
@@ -100,11 +101,15 @@ interface RemoteSnapshot {
|
|||||||
encrypted?: boolean
|
encrypted?: boolean
|
||||||
borg_id?: string // Borg-only fields when available
|
borg_id?: string // Borg-only fields when available
|
||||||
borg_start_iso?: string
|
borg_start_iso?: string
|
||||||
|
// The node the backup belongs to, read from its name; null when the name
|
||||||
|
// does not say (another node's backup under a name of its own choosing).
|
||||||
|
node?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RemoteArchivesResp {
|
interface RemoteArchivesResp {
|
||||||
snapshots: RemoteSnapshot[]
|
snapshots: RemoteSnapshot[]
|
||||||
errors: Array<{ backend: string; repo_name: string; error: string }>
|
errors: Array<{ backend: string; repo_name: string; error: string }>
|
||||||
|
hostname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportTask {
|
interface ExportTask {
|
||||||
@@ -132,6 +137,7 @@ interface UnifiedArchive {
|
|||||||
// One of the two is populated depending on `source`.
|
// One of the two is populated depending on `source`.
|
||||||
local?: BackupArchive
|
local?: BackupArchive
|
||||||
remote?: RemoteSnapshot // for both pbs and borg
|
remote?: RemoteSnapshot // for both pbs and borg
|
||||||
|
node: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BackupArchive {
|
interface BackupArchive {
|
||||||
@@ -146,6 +152,8 @@ interface BackupArchive {
|
|||||||
kind: "manual" | "scheduled" | "legacy"
|
kind: "manual" | "scheduled" | "legacy"
|
||||||
profile: string | null
|
profile: string | null
|
||||||
source_hostname: string | null
|
source_hostname: string | null
|
||||||
|
node?: string | null // the node the backup belongs to
|
||||||
|
former_job?: boolean // made by a job of this node that no longer exists
|
||||||
// Which detection path identified this archive. Surfaced as a small tooltip
|
// Which detection path identified this archive. Surfaced as a small tooltip
|
||||||
// hint so the operator knows whether the metadata is authoritative
|
// hint so the operator knows whether the metadata is authoritative
|
||||||
// (sidecar) or inferred (filename / tar-peek).
|
// (sidecar) or inferred (filename / tar-peek).
|
||||||
@@ -518,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
|||||||
await mutateInfo()
|
await mutateInfo()
|
||||||
closeUpload()
|
closeUpload()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : String(e))
|
setErr(hostBackupErrorText(e, t))
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
|||||||
await mutateInfo()
|
await mutateInfo()
|
||||||
closeDelete()
|
closeDelete()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : String(e))
|
setErr(hostBackupErrorText(e, t))
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,7 +766,7 @@ function KeyfileActionsBar({
|
|||||||
setPass1("")
|
setPass1("")
|
||||||
setPass2("")
|
setPass2("")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : String(e))
|
setErr(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -949,7 +957,7 @@ export function HostBackup() {
|
|||||||
mutateJobs()
|
mutateJobs()
|
||||||
setJobToDelete(null)
|
setJobToDelete(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` }))
|
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` }))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyJobId(null)
|
setBusyJobId(null)
|
||||||
}
|
}
|
||||||
@@ -964,11 +972,14 @@ export function HostBackup() {
|
|||||||
fetcher,
|
fetcher,
|
||||||
{ refreshInterval: 60000 },
|
{ refreshInterval: 60000 },
|
||||||
)
|
)
|
||||||
const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[] }>(
|
const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[]; hostname?: string }>(
|
||||||
"/api/host-backups/archives",
|
"/api/host-backups/archives",
|
||||||
fetcher,
|
fetcher,
|
||||||
{ refreshInterval: 30000 },
|
{ refreshInterval: 30000 },
|
||||||
)
|
)
|
||||||
|
// On a cluster every PBS holds the backups of all nodes: the list shows
|
||||||
|
// this node's by default, and all of them on request.
|
||||||
|
const [archiveScope, setArchiveScope] = useState<"node" | "all">("node")
|
||||||
|
|
||||||
// Background poll for the pending manual backup, independent of the
|
// Background poll for the pending manual backup, independent of the
|
||||||
// watch modal being open. Fires mutateArchives/mutateRemoteArchives
|
// watch modal being open. Fires mutateArchives/mutateRemoteArchives
|
||||||
@@ -1005,6 +1016,7 @@ export function HostBackup() {
|
|||||||
created_at: a.mtime,
|
created_at: a.mtime,
|
||||||
source_label: a.path.replace(/\/[^/]+$/, "") || "/",
|
source_label: a.path.replace(/\/[^/]+$/, "") || "/",
|
||||||
local: a,
|
local: a,
|
||||||
|
node: a.node ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1017,12 +1029,21 @@ export function HostBackup() {
|
|||||||
created_at: s.backup_time,
|
created_at: s.backup_time,
|
||||||
source_label: `${s.backend.toUpperCase()} ${s.repo_name}`,
|
source_label: `${s.backend.toUpperCase()} ${s.repo_name}`,
|
||||||
remote: s,
|
remote: s,
|
||||||
|
node: s.node ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.sort((a, b) => b.created_at - a.created_at)
|
out.sort((a, b) => b.created_at - a.created_at)
|
||||||
return out
|
return out
|
||||||
})()
|
})()
|
||||||
|
const thisNode = archivesResp?.hostname || remoteArchivesResp?.hostname || null
|
||||||
|
// The choice is offered only when some backup belongs to another node or
|
||||||
|
// its node is not known; a single node sees its list as before.
|
||||||
|
const otherNodeArchives = thisNode ? unifiedArchives.filter((u) => u.node !== thisNode).length : 0
|
||||||
|
const visibleArchives = archiveScope === "all" || otherNodeArchives === 0
|
||||||
|
? unifiedArchives
|
||||||
|
: unifiedArchives.filter((u) => u.node === thisNode)
|
||||||
|
const hiddenArchives = unifiedArchives.length - visibleArchives.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
@@ -1187,7 +1208,7 @@ export function HostBackup() {
|
|||||||
{!j.attached && j.next_run && (
|
{!j.attached && j.next_run && (
|
||||||
<span className="inline-flex items-center gap-1" title={t("backup.jobs.nextRunTitle")}>
|
<span className="inline-flex items-center gap-1" title={t("backup.jobs.nextRunTitle")}>
|
||||||
<Clock className="h-3.5 w-3.5 text-green-500" />
|
<Clock className="h-3.5 w-3.5 text-green-500" />
|
||||||
<span>{t("backup.jobs.nextRun", { time: formatNext(j.next_run) })}</span>
|
<span>{t("backup.jobs.nextRun")}: {formatNext(j.next_run)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(statusBadge || lastRunWhen) && (
|
{(statusBadge || lastRunWhen) && (
|
||||||
@@ -1278,7 +1299,32 @@ export function HostBackup() {
|
|||||||
<Archive className="h-5 w-5 text-blue-500" />
|
<Archive className="h-5 w-5 text-blue-500" />
|
||||||
<CardTitle className="text-base font-semibold">{t("backup.archives.title")}</CardTitle>
|
<CardTitle className="text-base font-semibold">{t("backup.archives.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{unifiedArchives.length}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
|
{otherNodeArchives > 0 && (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("backup.archives.scopeAriaLabel")}
|
||||||
|
className="inline-flex rounded-lg border border-border bg-muted/40 p-1 gap-1"
|
||||||
|
>
|
||||||
|
{(["node", "all"] as const).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={archiveScope === key}
|
||||||
|
onClick={() => setArchiveScope(key)}
|
||||||
|
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
|
archiveScope === key
|
||||||
|
? "bg-blue-500 text-white shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{key === "node" ? t("backup.archives.scopeThisNode") : t("backup.archives.scopeAllNodes")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline">{visibleArchives.length}</Badge>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-[11px] text-muted-foreground mb-3">
|
<p className="text-[11px] text-muted-foreground mb-3">
|
||||||
@@ -1289,7 +1335,7 @@ export function HostBackup() {
|
|||||||
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
|
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
|
||||||
{remoteArchivesResp.errors.map((e, i) => (
|
{remoteArchivesResp.errors.map((e, i) => (
|
||||||
<div key={i} className="font-mono break-all">
|
<div key={i} className="font-mono break-all">
|
||||||
{e.backend}/{e.repo_name}: {e.error}
|
{e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1301,13 +1347,21 @@ export function HostBackup() {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{t("backup.common.loading")}
|
{t("backup.common.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : unifiedArchives.length === 0 ? (
|
) : visibleArchives.length === 0 && hiddenArchives === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-4">
|
<div className="text-sm text-muted-foreground py-4">
|
||||||
{t("backup.archives.emptyMessage")}
|
{t("backup.archives.emptyMessage")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{unifiedArchives.map((u) => {
|
{hiddenArchives > 0 && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t("backup.archives.hiddenOtherNodes", { count: hiddenArchives })}{" "}
|
||||||
|
<button type="button" className="text-blue-400 hover:underline" onClick={() => setArchiveScope("all")}>
|
||||||
|
{t("backup.archives.scopeShowAll")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{visibleArchives.map((u) => {
|
||||||
const localKind = u.local?.kind
|
const localKind = u.local?.kind
|
||||||
const localJobId = u.local?.job_id
|
const localJobId = u.local?.job_id
|
||||||
const localHost = u.local?.source_hostname
|
const localHost = u.local?.source_hostname
|
||||||
@@ -1366,6 +1420,14 @@ export function HostBackup() {
|
|||||||
{t("backup.status.manual")}
|
{t("backup.status.manual")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{u.source === "local" && u.local?.former_job && (
|
||||||
|
<span
|
||||||
|
className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
||||||
|
title={t("backup.archives.formerJobTitle")}
|
||||||
|
>
|
||||||
|
{t("backup.archives.formerJob")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && (
|
{(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && (
|
||||||
<span>{u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: <code className="font-mono">{u.remote.backup_id}</code></span>
|
<span>{u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: <code className="font-mono">{u.remote.backup_id}</code></span>
|
||||||
)}
|
)}
|
||||||
@@ -1700,7 +1762,7 @@ function InspectModal({
|
|||||||
setImportFile(null)
|
setImportFile(null)
|
||||||
setImportPath("")
|
setImportPath("")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setImportError(e instanceof Error ? e.message : String(e))
|
setImportError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false)
|
setImporting(false)
|
||||||
}
|
}
|
||||||
@@ -1751,7 +1813,7 @@ function InspectModal({
|
|||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setRestoreError(e instanceof Error ? e.message : String(e))
|
setRestoreError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setRestorePreparing(false)
|
setRestorePreparing(false)
|
||||||
}
|
}
|
||||||
@@ -1780,7 +1842,7 @@ function InspectModal({
|
|||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||||
} finally {
|
} finally {
|
||||||
// The download itself runs in the browser's network stack;
|
// The download itself runs in the browser's network stack;
|
||||||
// we just initiated it. Clear the spinner immediately.
|
// we just initiated it. Clear the spinner immediately.
|
||||||
@@ -1853,7 +1915,7 @@ function InspectModal({
|
|||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
setExportTask(null)
|
setExportTask(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(false)
|
setDownloading(false)
|
||||||
}
|
}
|
||||||
@@ -1901,7 +1963,7 @@ function InspectModal({
|
|||||||
setShowDeleteArchiveConfirm(false)
|
setShowDeleteArchiveConfirm(false)
|
||||||
if (onDeleted) onDeleted(); else onClose()
|
if (onDeleted) onDeleted(); else onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) }))
|
setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) }))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingArchive(false)
|
setDeletingArchive(false)
|
||||||
}
|
}
|
||||||
@@ -1928,7 +1990,7 @@ function InspectModal({
|
|||||||
) as NonNullable<typeof instructions>
|
) as NonNullable<typeof instructions>
|
||||||
setInstructions(res)
|
setInstructions(res)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setFetchingInstructions(false)
|
setFetchingInstructions(false)
|
||||||
}
|
}
|
||||||
@@ -1960,7 +2022,7 @@ function InspectModal({
|
|||||||
)
|
)
|
||||||
setReport(res)
|
setReport(res)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || t("backup.errors.preflightFailed"))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
}
|
}
|
||||||
@@ -2035,7 +2097,19 @@ function InspectModal({
|
|||||||
<div><span className="text-muted-foreground">{t("backup.fields.createdLabel")}</span> {formatMtime(localArc.mtime)}</div>
|
<div><span className="text-muted-foreground">{t("backup.fields.createdLabel")}</span> {formatMtime(localArc.mtime)}</div>
|
||||||
<div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(localArc.size_bytes)}</div>
|
<div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(localArc.size_bytes)}</div>
|
||||||
<div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.pathLabel")}</span> <code className="font-mono break-all">{localArc.path}</code></div>
|
<div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.pathLabel")}</span> <code className="font-mono break-all">{localArc.path}</code></div>
|
||||||
{localArc.job_id && <div><span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code></div>}
|
{localArc.job_id && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code>
|
||||||
|
{localArc.former_job && (
|
||||||
|
<span
|
||||||
|
className="ml-2 uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400"
|
||||||
|
title={t("backup.archives.formerJobTitle")}
|
||||||
|
>
|
||||||
|
{t("backup.archives.formerJob")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{localArc.profile && <div><span className="text-muted-foreground">{t("backup.fields.profileLabel")}</span> <code className="font-mono">{localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}</code></div>}
|
{localArc.profile && <div><span className="text-muted-foreground">{t("backup.fields.profileLabel")}</span> <code className="font-mono">{localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}</code></div>}
|
||||||
{localArc.source_hostname && <div><span className="text-muted-foreground">{t("backup.fields.sourceHostLabel")}</span> <code className="font-mono">{localArc.source_hostname}</code></div>}
|
{localArc.source_hostname && <div><span className="text-muted-foreground">{t("backup.fields.sourceHostLabel")}</span> <code className="font-mono">{localArc.source_hostname}</code></div>}
|
||||||
<div><span className="text-muted-foreground">{t("backup.fields.detectedViaLabel")}</span> <code className="font-mono text-[10px]">{localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}</code></div>
|
<div><span className="text-muted-foreground">{t("backup.fields.detectedViaLabel")}</span> <code className="font-mono text-[10px]">{localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}</code></div>
|
||||||
@@ -2091,7 +2165,7 @@ function InspectModal({
|
|||||||
<span className="text-muted-foreground">— {exportTask.message}</span>
|
<span className="text-muted-foreground">— {exportTask.message}</span>
|
||||||
</div>
|
</div>
|
||||||
{exportTask.state === "failed" && exportTask.error && (
|
{exportTask.state === "failed" && exportTask.error && (
|
||||||
<div className="text-red-500 mt-1">{exportTask.error}</div>
|
<div className="text-red-500 mt-1">{hostBackupErrorText(exportTask.error, t)}</div>
|
||||||
)}
|
)}
|
||||||
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
|
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
|
||||||
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
|
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
|
||||||
@@ -2633,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
|
|||||||
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
|
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className={`font-mono ${color}`}>{c.id}</span>
|
<span className={`font-mono ${color}`}>{c.id}</span>
|
||||||
<span className="text-muted-foreground ml-2">{c.message}</span>
|
<span className="text-muted-foreground ml-2">{hostBackupErrorText(c.message, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -3253,9 +3327,9 @@ function CreateJobDialog({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||||
const detail = err.body?.tool_output
|
const detail = err.body?.tool_output
|
||||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||||
: err.message
|
: hostBackupErrorText(err, t)
|
||||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||||
setPbsImportBusy(false)
|
setPbsImportBusy(false)
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
return
|
return
|
||||||
@@ -3318,7 +3392,7 @@ function CreateJobDialog({
|
|||||||
})
|
})
|
||||||
onCreated()
|
onCreated()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -3691,7 +3765,7 @@ function CreateJobDialog({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-red-500">
|
<div className="text-red-500">
|
||||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
|
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {hostBackupErrorText(calendarPreview.error, t)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -4623,9 +4697,9 @@ function ManualBackupDialog({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||||
const detail = err.body?.tool_output
|
const detail = err.body?.tool_output
|
||||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||||
: err.message
|
: hostBackupErrorText(err, t)
|
||||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||||
setPbsImportBusy(false)
|
setPbsImportBusy(false)
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
return
|
return
|
||||||
@@ -4665,7 +4739,7 @@ function ManualBackupDialog({
|
|||||||
)
|
)
|
||||||
onLaunched(resp.job_id)
|
onLaunched(resp.job_id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -5469,7 +5543,7 @@ function DestinationsSection({
|
|||||||
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||||
onChanged()
|
onChanged()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -5481,7 +5555,7 @@ function DestinationsSection({
|
|||||||
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||||
onChanged()
|
onChanged()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -5498,7 +5572,7 @@ function DestinationsSection({
|
|||||||
)
|
)
|
||||||
onChanged()
|
onChanged()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -5526,7 +5600,7 @@ function DestinationsSection({
|
|||||||
})
|
})
|
||||||
onChanged()
|
onChanged()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -5969,7 +6043,7 @@ function DestinationRow({
|
|||||||
|
|
||||||
{capacity?.error && (
|
{capacity?.error && (
|
||||||
<p className="mt-3 text-[11px] text-muted-foreground italic">
|
<p className="mt-3 text-[11px] text-muted-foreground italic">
|
||||||
{t("backup.destinations.capacityUnavailable", { error: capacity.error })}
|
{t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!capacity && (
|
{!capacity && (
|
||||||
@@ -6268,7 +6342,7 @@ function AddDestinationDialog({
|
|||||||
)
|
)
|
||||||
setGeneratedKey(resp)
|
setGeneratedKey(resp)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setGeneratingKey(false)
|
setGeneratingKey(false)
|
||||||
}
|
}
|
||||||
@@ -6332,7 +6406,7 @@ function AddDestinationDialog({
|
|||||||
}
|
}
|
||||||
onSaved(savedRepo)
|
onSaved(savedRepo)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -6683,7 +6757,7 @@ function UsbPicker({
|
|||||||
mutate()
|
mutate()
|
||||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -6711,7 +6785,7 @@ function UsbPicker({
|
|||||||
setFormatTyped("")
|
setFormatTyped("")
|
||||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -6928,7 +7002,7 @@ function ExtraPathsSection() {
|
|||||||
setNewPath("")
|
setNewPath("")
|
||||||
mutate()
|
mutate()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -6941,7 +7015,7 @@ function ExtraPathsSection() {
|
|||||||
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
|
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
|
||||||
mutate()
|
mutate()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyPath(null)
|
setBusyPath(null)
|
||||||
}
|
}
|
||||||
@@ -7107,7 +7181,7 @@ function UsbDrivesSection() {
|
|||||||
})
|
})
|
||||||
mutate()
|
mutate()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -7125,7 +7199,7 @@ function UsbDrivesSection() {
|
|||||||
})
|
})
|
||||||
mutate()
|
mutate()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -7149,7 +7223,7 @@ function UsbDrivesSection() {
|
|||||||
setFormatTarget(null)
|
setFormatTarget(null)
|
||||||
setFormatTyped("")
|
setFormatTyped("")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusyKey(null)
|
setBusyKey(null)
|
||||||
}
|
}
|
||||||
@@ -7435,7 +7509,7 @@ function JobDetailModal({
|
|||||||
setRunning(false)
|
setRunning(false)
|
||||||
setRunBaseline(null)
|
setRunBaseline(null)
|
||||||
setRunBaselineLogPath(null)
|
setRunBaselineLogPath(null)
|
||||||
setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) }))
|
setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7449,7 +7523,7 @@ function JobDetailModal({
|
|||||||
await refetch()
|
await refetch()
|
||||||
onChanged()
|
onChanged()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) }))
|
setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) }))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy("")
|
setBusy("")
|
||||||
}
|
}
|
||||||
@@ -8082,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({
|
|||||||
})
|
})
|
||||||
onRecovered()
|
onRecovered()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(hostBackupErrorText(e, t))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -8269,7 +8343,7 @@ function ArchiveContentsModal({
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
.then((r: any) => setData(r))
|
.then((r: any) => setData(r))
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
.catch((e) => setError(hostBackupErrorText(e, t)))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [open, source, repo_name, snapshot, path])
|
}, [open, source, repo_name, snapshot, path])
|
||||||
|
|
||||||
@@ -8498,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
|
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
|
||||||
<ul className="space-y-0.5 ml-4 list-disc">
|
<ul className="space-y-0.5 ml-4 list-disc">
|
||||||
{blockers.map((b, i) => (
|
{blockers.map((b, i) => (
|
||||||
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
|
<li key={i} className="text-red-400">{hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -8508,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
|
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
|
||||||
<ul className="space-y-0.5 ml-4 list-disc">
|
<ul className="space-y-0.5 ml-4 list-disc">
|
||||||
{warnings.map((w, i) => (
|
{warnings.map((w, i) => (
|
||||||
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
|
<li key={i} className="text-amber-400">{hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -8538,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
|
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
|
||||||
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
|
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
|
||||||
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
|
{e.message && <div className="text-muted-foreground text-[11px]">{hostBackupErrorText(e.message, t)}</div>}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
export type HostUpdateJob = {
|
||||||
|
id: string
|
||||||
|
state: "starting" | "running" | "attention" | "finalizing" | "complete"
|
||||||
|
exit_code: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }: {
|
||||||
|
open: boolean
|
||||||
|
activeId?: string
|
||||||
|
onResume: (job: HostUpdateJob) => void
|
||||||
|
onJobs: (jobs: HostUpdateJob[]) => void
|
||||||
|
onSettled: () => void
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
|
||||||
|
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||||
|
const callbacks = useRef({ onJobs, onSettled })
|
||||||
|
callbacks.current = { onJobs, onSettled }
|
||||||
|
const observed = useRef(new Set<string>())
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
let closed = false
|
||||||
|
const read = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchApi<{ jobs: HostUpdateJob[] }>("/api/host-update-jobs")
|
||||||
|
if (closed) return
|
||||||
|
const next = Array.isArray(data.jobs) ? data.jobs : []
|
||||||
|
setJobs(next)
|
||||||
|
callbacks.current.onJobs(next)
|
||||||
|
for (const job of next) {
|
||||||
|
if (job.state === "complete" && !observed.current.has(job.id)) {
|
||||||
|
observed.current.add(job.id)
|
||||||
|
callbacks.current.onSettled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Authentication recovery belongs to fetchApi. Never retry a start.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void read()
|
||||||
|
const timer = setInterval(() => { void read() }, 5000)
|
||||||
|
return () => { closed = true; clearInterval(timer) }
|
||||||
|
}, [open])
|
||||||
|
const visible = jobs.filter(job => job.id !== activeId && !(job.state === "complete" && hidden.has(job.id)))
|
||||||
|
if (!visible.length) return null
|
||||||
|
return <div className="space-y-2" role="status">
|
||||||
|
{visible.map(job => <div key={job.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
|
||||||
|
<p className="text-sm">{t(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
|
||||||
|
{t(job.state === "complete" ? "healthStatus.hostUpdate.viewResult" : "healthStatus.hostUpdate.view")}
|
||||||
|
</Button>
|
||||||
|
{job.state === "complete" && <Button size="sm" variant="ghost" onClick={() => setHidden(previous => new Set([...previous, job.id]))}>
|
||||||
|
{t("healthStatus.hostUpdate.hideResult")}
|
||||||
|
</Button>}
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { getAuthToken } from "../lib/api-config"
|
||||||
|
import { scriptCompletion } from "../lib/script-completion"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import "xterm/css/xterm.css"
|
||||||
|
|
||||||
|
export function HostUpdateResult({ jobId, getApiUrl, onClose }: {
|
||||||
|
jobId: string | null
|
||||||
|
getApiUrl: (path: string) => string
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const container = useRef<HTMLDivElement>(null)
|
||||||
|
const [revision, setRevision] = useState(0)
|
||||||
|
const [exitCode, setExitCode] = useState<number | null>(null)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobId) return
|
||||||
|
const controller = new AbortController()
|
||||||
|
let terminal: import("xterm").Terminal | undefined
|
||||||
|
let observer: ResizeObserver | undefined
|
||||||
|
let disposed = false
|
||||||
|
setExitCode(null); setFailed(false)
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const token = getAuthToken()
|
||||||
|
const response = await fetch(getApiUrl(`/api/host-update-jobs/${encodeURIComponent(jobId)}/result`), {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw Error("unavailable")
|
||||||
|
const value = await response.json()
|
||||||
|
if (value.state !== "complete" || !Number.isSafeInteger(value.exit_code)
|
||||||
|
|| typeof value.text !== "string" || value.text.length > 256 * 1024
|
||||||
|
|| typeof value.truncated !== "boolean") throw Error("invalid result")
|
||||||
|
const [{ Terminal }, { FitAddon }] = await Promise.all([import("xterm"), import("xterm-addon-fit")])
|
||||||
|
if (controller.signal.aborted || !container.current) return
|
||||||
|
terminal = new Terminal({ disableStdin: true, convertEol: true, fontSize: 12,
|
||||||
|
screenReaderMode: true, theme: { background: "#000000", foreground: "#dddddd" } })
|
||||||
|
const fit = new FitAddon()
|
||||||
|
terminal.loadAddon(fit); terminal.open(container.current); fit.fit()
|
||||||
|
if (value.truncated) terminal.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||||
|
terminal.write(value.text)
|
||||||
|
observer = new ResizeObserver(() => { if (!controller.signal.aborted) fit.fit() })
|
||||||
|
observer.observe(container.current)
|
||||||
|
setExitCode(value.exit_code)
|
||||||
|
} catch {
|
||||||
|
if (!disposed) setFailed(true)
|
||||||
|
} finally { clearTimeout(timeout) }
|
||||||
|
})()
|
||||||
|
return () => { disposed = true; controller.abort(); clearTimeout(timeout); observer?.disconnect(); terminal?.dispose() }
|
||||||
|
}, [jobId, getApiUrl, revision, t])
|
||||||
|
const kind = exitCode === null ? null : scriptCompletion(exitCode, 130).kind
|
||||||
|
return <Dialog open={jobId !== null} onOpenChange={open => { if (!open) onClose() }}>
|
||||||
|
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto p-4 sm:p-5">
|
||||||
|
<DialogHeader><DialogTitle>{t("healthStatus.hostUpdate.resultTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("healthStatus.hostUpdate.resultReadOnly")}</DialogDescription></DialogHeader>
|
||||||
|
{failed && <div role="alert" className="text-sm text-amber-500">
|
||||||
|
<p>{t("healthStatus.hostUpdate.resultUnavailable")}</p>
|
||||||
|
<Button size="sm" variant="outline" className="mt-3" onClick={() => setRevision(value => value + 1)}>{t("healthStatus.hostUpdate.retryResult")}</Button>
|
||||||
|
</div>}
|
||||||
|
<div ref={container} className="h-[45vh] min-h-[180px] bg-black rounded overflow-hidden" />
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p role="status" className={`text-xs ${exitCode === 0 ? "text-green-500" : "text-muted-foreground"}`}>
|
||||||
|
{kind === null ? t(failed ? "healthStatus.hostUpdate.resultUnavailable" : "healthStatus.hostUpdate.loadingResult")
|
||||||
|
: kind === "cancelled" ? t("scriptTerminal.cancelled") : exitCode === 0
|
||||||
|
? t("scriptTerminal.completedSuccessfully") : t("scriptTerminal.completedWithError", { code: exitCode })}
|
||||||
|
</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={onClose}>{t("healthStatus.hostUpdate.closeResult")}</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||||
|
import * as Confirm from "@radix-ui/react-alert-dialog"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { getAuthToken } from "@/lib/api-config"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
import { CheckCircle2, AlertTriangle, Info, ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
const token = getAuthToken()
|
||||||
|
return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }
|
||||||
|
}
|
||||||
|
type Url = (path: string) => string
|
||||||
|
type Context = { hostname: string; boot_id: string; allowed: boolean; reason: string }
|
||||||
|
const compact = "h-10 sm:h-[34px] px-3 text-xs"
|
||||||
|
|
||||||
|
export function RestartAfterUpdate({ getApiUrl, disabled, unavailable = false, label, detail, icon }: {
|
||||||
|
getApiUrl: Url; disabled: boolean; unavailable?: boolean; label: string; detail: string; icon: ReactNode
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [context, setContext] = useState<Context | null>(null)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [accepted, setAccepted] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const sending = useRef(false)
|
||||||
|
const alive = useRef(true)
|
||||||
|
const postRequest = useRef<AbortController | null>(null)
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, [])
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false, reading = false
|
||||||
|
let active: AbortController | undefined
|
||||||
|
setContext(null); setSubmitted(false); setError(false); sending.current = false
|
||||||
|
const read = async () => {
|
||||||
|
if (reading) return
|
||||||
|
reading = true
|
||||||
|
const controller = new AbortController(); active = controller
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 6000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(getApiUrl("/api/health/reboot-context"), { headers: headers(), signal: controller.signal })
|
||||||
|
if (!response.ok) throw Error("unavailable")
|
||||||
|
const value: Context = await response.json()
|
||||||
|
if (!value || typeof value.hostname !== "string" || typeof value.allowed !== "boolean" || typeof value.boot_id !== "string" || !value.boot_id) throw Error("invalid")
|
||||||
|
if (!disposed) { setContext(value); setError(false) }
|
||||||
|
} catch { if (!disposed) { setContext(null); setError(true) } }
|
||||||
|
finally { clearTimeout(timeout); reading = false }
|
||||||
|
}
|
||||||
|
void read()
|
||||||
|
// A retained health snapshot must never grant permission after reconnect
|
||||||
|
// or remount. This cheap read does not run APT or a full health check.
|
||||||
|
const interval = setInterval(read, open ? 2000 : 30000)
|
||||||
|
return () => { disposed = true; clearInterval(interval); active?.abort() }
|
||||||
|
}, [open, getApiUrl])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accepted) return
|
||||||
|
let disposed = false, reading = false
|
||||||
|
const controller = new AbortController()
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (reading) return
|
||||||
|
reading = true
|
||||||
|
try {
|
||||||
|
const response = await fetch(getApiUrl("/api/health/reboot-after-update/status"), { headers: headers(), signal: controller.signal })
|
||||||
|
if (!response.ok) return
|
||||||
|
const value = await response.json()
|
||||||
|
if (!disposed && (value.state === "failed" || value.state === "cancelled")) { setAccepted(false); setError(true) }
|
||||||
|
} catch { /* Loss of connection does not prove reboot completion. */ }
|
||||||
|
finally { reading = false }
|
||||||
|
}, 2000)
|
||||||
|
const timeout = setTimeout(() => { clearInterval(interval); controller.abort() }, 10000)
|
||||||
|
return () => { disposed = true; controller.abort(); clearInterval(interval); clearTimeout(timeout) }
|
||||||
|
}, [accepted, getApiUrl])
|
||||||
|
const confirm = async () => {
|
||||||
|
if (disabled || unavailable || !context?.allowed || sending.current || submitted) return
|
||||||
|
sending.current = true; setSubmitted(true); setPending(true); setError(false)
|
||||||
|
const controller = new AbortController(); postRequest.current = controller
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(getApiUrl("/api/health/reboot-after-update"), {
|
||||||
|
method: "POST", headers: headers(), body: JSON.stringify({ confirm: true, hostname: context.hostname, boot_id: context.boot_id }), signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (response.status !== 202 || (await response.json()).accepted !== true) throw Error("unconfirmed")
|
||||||
|
if (alive.current) { setAccepted(true); setOpen(false) }
|
||||||
|
} catch { if (alive.current) setError(true) }
|
||||||
|
finally { clearTimeout(timeout); postRequest.current = null; if (alive.current) setPending(false) }
|
||||||
|
}
|
||||||
|
return <section className="py-1.5 px-2 sm:px-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="flex items-start gap-2 flex-1 min-w-0 text-[10px] sm:text-xs"><span className="shrink-0">{icon}</span>
|
||||||
|
<div><span className="font-medium mr-2">{label}</span><span className="text-muted-foreground break-words">{detail}</span></div></div>
|
||||||
|
<Button variant="outline" className={`${compact} self-start sm:shrink-0 border-amber-500/40 text-amber-500`}
|
||||||
|
disabled={disabled || unavailable || !context?.allowed || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}</Button>
|
||||||
|
</div>
|
||||||
|
{disabled && <p className="mt-2 text-xs text-muted-foreground">{t("healthStatus.restart.blocked")}</p>}
|
||||||
|
{accepted && <p role="status" className="mt-2 text-xs text-blue-500">{t("healthStatus.restart.accepted")}</p>}
|
||||||
|
{error && !open && <p role="alert" className="mt-2 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
|
||||||
|
<Confirm.Root open={open} onOpenChange={setOpen}><Confirm.Portal>
|
||||||
|
<Confirm.Overlay className="fixed inset-0 bg-black/75 z-[70]" />
|
||||||
|
<Confirm.Content className="fixed z-[80] w-[92vw] max-w-[520px] rounded-lg border bg-card text-foreground p-5 shadow-lg" style={{ top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
|
||||||
|
<Confirm.Title className="text-base font-semibold">{t("healthStatus.restart.title", { server: context?.hostname ?? "…" })}</Confirm.Title>
|
||||||
|
<Confirm.Description className="mt-3 text-sm text-muted-foreground">{t("healthStatus.restart.impact")}</Confirm.Description>
|
||||||
|
{(disabled || context?.reason === "busy") && <p className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.blocked")}</p>}
|
||||||
|
{context?.reason === "current" && <p className="mt-3 text-xs text-muted-foreground">{t("healthStatus.hostUpdate.noReboot")}</p>}
|
||||||
|
{(error || context?.reason === "unknown") && <p role="alert" className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
|
||||||
|
<div className="mt-5 flex justify-end gap-2.5">
|
||||||
|
<Confirm.Cancel asChild><Button variant="outline" className={compact} disabled={pending}>{t("healthStatus.restart.cancel")}</Button></Confirm.Cancel>
|
||||||
|
<Confirm.Action asChild><Button variant="destructive" className={compact} disabled={disabled || unavailable || !context?.allowed || pending || submitted}
|
||||||
|
onClick={event => { event.preventDefault(); void confirm() }}>{t("healthStatus.restart.confirm")}</Button></Confirm.Action>
|
||||||
|
</div>
|
||||||
|
</Confirm.Content>
|
||||||
|
</Confirm.Portal></Confirm.Root>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Versions = { installed: string | null; available: string | null; state: "available" | "current" | "unknown"; checked_at: string | null; channel: "stable" | "beta" }
|
||||||
|
export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) {
|
||||||
|
const t = useT()
|
||||||
|
const [value, setValue] = useState<Versions | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false, reading = false
|
||||||
|
let active: AbortController | undefined
|
||||||
|
const read = async () => {
|
||||||
|
if (reading || document.visibilityState !== "visible") return
|
||||||
|
reading = true
|
||||||
|
const controller = new AbortController(); active = controller
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 6000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(getApiUrl("/api/health/proxmenux-version"), { headers: headers(), signal: controller.signal })
|
||||||
|
if (!response.ok) throw Error("unavailable")
|
||||||
|
const data: Versions = await response.json()
|
||||||
|
if (!["available", "current", "unknown"].includes(data?.state)) throw Error("invalid")
|
||||||
|
const validVersion = (version: unknown) => typeof version === "string" && /^\d+(?:\.\d+){1,5}$/.test(version)
|
||||||
|
if (!["stable", "beta"].includes(data.channel) ||
|
||||||
|
(data.installed !== null && !validVersion(data.installed)) ||
|
||||||
|
(data.available !== null && !validVersion(data.available)) ||
|
||||||
|
(data.state !== "unknown" && (!validVersion(data.installed) || !validVersion(data.available) || !data.checked_at)) ||
|
||||||
|
(data.checked_at !== null && !Number.isFinite(Date.parse(data.checked_at)))) throw Error("invalid")
|
||||||
|
if (!disposed) setValue(data)
|
||||||
|
} catch { if (!disposed) setValue(previous => previous ? { ...previous, state: "unknown" } : null) }
|
||||||
|
finally { clearTimeout(timeout); reading = false }
|
||||||
|
}
|
||||||
|
void read()
|
||||||
|
const interval = setInterval(read, 300000)
|
||||||
|
window.addEventListener("focus", read); document.addEventListener("visibilitychange", read)
|
||||||
|
return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) }
|
||||||
|
}, [getApiUrl])
|
||||||
|
const state = value?.state ?? "unknown"
|
||||||
|
const versions = <dl className="grid grid-cols-2 gap-4">
|
||||||
|
<div><dt className="text-muted-foreground mb-1">{t("healthStatus.versions.installed")}</dt><dd className="font-mono font-semibold">{value?.installed ?? t("healthStatus.versions.unverified")}</dd></div>
|
||||||
|
<div><dt className="text-muted-foreground mb-1">{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}</dt><dd className="font-mono font-semibold">{value?.available ?? t("healthStatus.versions.unverified")}</dd></div>
|
||||||
|
</dl>
|
||||||
|
const metadata = <>
|
||||||
|
{value && <p className="mt-3 text-muted-foreground">{t(`healthStatus.versions.${value.channel}`)}</p>}
|
||||||
|
{value?.checked_at && <p className="mt-1 text-muted-foreground">{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}</p>}
|
||||||
|
</>
|
||||||
|
if (state !== "available") return <details aria-label={t("healthStatus.versions.title")} className="group text-[10px] sm:text-xs">
|
||||||
|
<summary className="flex items-start gap-2 py-1.5 px-2 sm:px-3 rounded-md hover:bg-muted/40 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||||
|
{state === "current" ? <CheckCircle2 aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-green-500" /> : <AlertTriangle aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-amber-500" />}
|
||||||
|
<span className="font-medium shrink-0">ProxMenux</span>
|
||||||
|
<span className={`flex-1 min-w-0 break-words ${state === "current" ? "text-muted-foreground" : "text-amber-500"}`}>
|
||||||
|
{state === "current" ? t("healthStatus.versions.currentVersion", { version: value!.installed! }) : t("healthStatus.versions.unknown")}
|
||||||
|
</span>
|
||||||
|
<ChevronRight aria-hidden="true" className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||||
|
</summary>
|
||||||
|
<div className="ml-7 mr-2 sm:mr-3 pb-2">
|
||||||
|
{state === "unknown" && versions}
|
||||||
|
{metadata}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
return <section aria-label={t("healthStatus.versions.title")} className="mt-3 mx-2 sm:mx-3 rounded-lg border bg-muted/10 p-3 text-[10px] sm:text-xs">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-3"><h3 className="font-semibold">{t("healthStatus.versions.title")}</h3>
|
||||||
|
<span className="flex items-center gap-1.5 text-blue-500"><Info aria-hidden="true" className="h-3.5 w-3.5" />{t("healthStatus.versions.available")}</span></div>
|
||||||
|
{versions}
|
||||||
|
{metadata}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -255,6 +255,8 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Password managers read the field's siblings and skip it when they mention a backup code. */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="totp-code" className="text-sm">
|
<Label htmlFor="totp-code" className="text-sm">
|
||||||
{t("login.authenticationCode")}
|
{t("login.authenticationCode")}
|
||||||
@@ -271,6 +273,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
{t("login.backupCodeHint")}
|
{t("login.backupCodeHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* Secure Gateway.
|
* Secure Gateway.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
Loader2, Save, RefreshCw, Trash2, Package, ExternalLink,
|
Loader2, Save, RefreshCw, Trash2, Package, ExternalLink,
|
||||||
AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff,
|
AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff,
|
||||||
@@ -32,11 +32,12 @@ import { Input } from "./ui/input"
|
|||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||||
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache"
|
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache"
|
||||||
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
||||||
import { getCategoryLabel } from "../lib/category-label"
|
import { getCategoryLabel } from "../lib/category-label"
|
||||||
import { useT } from "@/lib/i18n/provider"
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
import { localizeAppEditorError } from "../lib/lxc-app-editor-errors"
|
||||||
|
|
||||||
// installed_via is optional now — an empty value means "register only,
|
// installed_via is optional now — an empty value means "register only,
|
||||||
// no version tracking, no warnings, just a clickable link". Docker
|
// no version tracking, no warnings, just a clickable link". Docker
|
||||||
@@ -93,6 +94,7 @@ interface AppConfig {
|
|||||||
health_path?: string
|
health_path?: string
|
||||||
logo_url?: string
|
logo_url?: string
|
||||||
helper_slug?: string
|
helper_slug?: string
|
||||||
|
discovery_id?: string
|
||||||
// Set when the application runs inside Docker: registered for its identity
|
// Set when the application runs inside Docker: registered for its identity
|
||||||
// and installed version, updated by the image it comes from.
|
// and installed version, updated by the image it comes from.
|
||||||
update_via?: string
|
update_via?: string
|
||||||
@@ -121,7 +123,19 @@ interface PortDetail {
|
|||||||
logo_url: string
|
logo_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NativeDiscovery {
|
||||||
|
vmid: number
|
||||||
|
status: "not_scanned" | "complete" | "partial" | "error" | "busy"
|
||||||
|
issues?: string[]
|
||||||
|
stale: boolean
|
||||||
|
candidates: DetectedApp[]
|
||||||
|
}
|
||||||
|
|
||||||
interface DetectedApp {
|
interface DetectedApp {
|
||||||
|
discovery_id?: string
|
||||||
|
source?: "native_metadata" | "docker_metadata"
|
||||||
|
installed_version?: string | null
|
||||||
|
evidence?: Array<{ kind: string; value: string }>
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
logo_url?: string | null
|
logo_url?: string | null
|
||||||
@@ -343,6 +357,7 @@ interface ManagedAppInfo {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vmid: number
|
vmid: number
|
||||||
|
cacheRevision?: number
|
||||||
ctIp?: string | null
|
ctIp?: string | null
|
||||||
onChange?: () => void
|
onChange?: () => void
|
||||||
managed?: ManagedAppInfo | null
|
managed?: ManagedAppInfo | null
|
||||||
@@ -382,23 +397,75 @@ const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: strin
|
|||||||
lightTheme: `${SELFHST_WEBP_BASE}/frigate-dark.webp`,
|
lightTheme: `${SELFHST_WEBP_BASE}/frigate-dark.webp`,
|
||||||
darkTheme: `${SELFHST_WEBP_BASE}/frigate-light.webp`,
|
darkTheme: `${SELFHST_WEBP_BASE}/frigate-light.webp`,
|
||||||
},
|
},
|
||||||
|
"hermes-agent": {
|
||||||
|
lightTheme: `${SELFHST_WEBP_BASE}/hermes-agent.webp`,
|
||||||
|
darkTheme: `${SELFHST_WEBP_BASE}/hermes-agent-light.webp`,
|
||||||
|
},
|
||||||
|
"llama-cpp": {
|
||||||
|
lightTheme: `${SELFHST_WEBP_BASE}/llama-cpp.webp`,
|
||||||
|
darkTheme: `${SELFHST_WEBP_BASE}/llama-cpp-light.webp`,
|
||||||
|
},
|
||||||
portainer: {
|
portainer: {
|
||||||
lightTheme: `${SELFHST_WEBP_BASE}/portainer-dark.webp`,
|
lightTheme: `${SELFHST_WEBP_BASE}/portainer-dark.webp`,
|
||||||
darkTheme: `${SELFHST_WEBP_BASE}/portainer-light.webp`,
|
darkTheme: `${SELFHST_WEBP_BASE}/portainer-light.webp`,
|
||||||
},
|
},
|
||||||
|
tailscale: {
|
||||||
|
lightTheme: `${SELFHST_WEBP_BASE}/tailscale.webp`,
|
||||||
|
darkTheme: `${SELFHST_WEBP_BASE}/tailscale-light.webp`,
|
||||||
|
},
|
||||||
vaultwarden: {
|
vaultwarden: {
|
||||||
lightTheme: `${SELFHST_WEBP_BASE}/vaultwarden.webp`,
|
lightTheme: `${SELFHST_WEBP_BASE}/vaultwarden.webp`,
|
||||||
darkTheme: `${SELFHST_WEBP_BASE}/vaultwarden-light.webp`,
|
darkTheme: `${SELFHST_WEBP_BASE}/vaultwarden-light.webp`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Earlier catalog icons that now have a themed selfhst equivalent.
|
||||||
|
const THEMED_LOGO_ALIASES: Record<string, string> = {
|
||||||
|
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/webp/hermes.webp": "hermes-agent",
|
||||||
|
}
|
||||||
|
|
||||||
function selfhstThemeLogos(src: string) {
|
function selfhstThemeLogos(src: string) {
|
||||||
|
const alias = THEMED_LOGO_ALIASES[src.toLowerCase()]
|
||||||
|
if (alias) return SELFHST_THEME_LOGOS[alias]
|
||||||
if (!src.toLowerCase().includes("cdn.jsdelivr.net/gh/selfhst/icons")) return null
|
if (!src.toLowerCase().includes("cdn.jsdelivr.net/gh/selfhst/icons")) return null
|
||||||
const match = src.toLowerCase().match(/\/(frigate|portainer|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
|
const match = src.toLowerCase().match(/\/(frigate|hermes-agent|llama-cpp|portainer|tailscale|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
|
||||||
return match ? SELFHST_THEME_LOGOS[match[1]] : null
|
return match ? SELFHST_THEME_LOGOS[match[1]] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOCAL_LOGO_CACHE_PATH = /^\/api\/vms\/[1-9]\d*\/apps\/logo-cache\/[a-f0-9]{32}$/
|
||||||
|
|
||||||
|
function CachedLocalLogo({ src, className }: { src: string; className: string }) {
|
||||||
|
const [image, setImage] = useState<{ src: string; url: string } | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
let active = true
|
||||||
|
let url: string | null = null
|
||||||
|
const token = getAuthToken()
|
||||||
|
fetch(getApiUrl(src), {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
signal: controller.signal, cache: "no-store",
|
||||||
|
}).then(async response => {
|
||||||
|
if (!response.ok) throw new Error("cached icon unavailable")
|
||||||
|
const blob = await response.blob()
|
||||||
|
const mime = blob.type.split(";", 1)[0].trim().toLowerCase()
|
||||||
|
if (!["image/png", "image/svg+xml"].includes(mime) || blob.size > 262144) {
|
||||||
|
throw new Error("invalid cached icon")
|
||||||
|
}
|
||||||
|
if (!active) return
|
||||||
|
url = URL.createObjectURL(blob)
|
||||||
|
setImage({ src, url })
|
||||||
|
}).catch(() => { if (active) setImage(null) })
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
controller.abort()
|
||||||
|
if (url) URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
return image?.src === src ? <img src={image.url} alt="" className={className} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeAwareLogo({ src, className }: { src: string; className: string }) {
|
export function ThemeAwareLogo({ src, className }: { src: string; className: string }) {
|
||||||
|
if (LOCAL_LOGO_CACHE_PATH.test(src)) return <CachedLocalLogo src={src} className={className} />
|
||||||
const themed = selfhstThemeLogos(src)
|
const themed = selfhstThemeLogos(src)
|
||||||
const hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
const hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
e.currentTarget.style.display = "none"
|
e.currentTarget.style.display = "none"
|
||||||
@@ -451,7 +518,7 @@ function parseArgvInput(value: string): string[] {
|
|||||||
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
|
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, cacheRevision = 0 }: Props) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const isLightTheme = useIsLightTheme()
|
const isLightTheme = useIsLightTheme()
|
||||||
// Seed from `initialData` first, then fall back to the shared cache
|
// Seed from `initialData` first, then fall back to the shared cache
|
||||||
@@ -462,12 +529,77 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
const [loading, setLoading] = useState(!seed)
|
const [loading, setLoading] = useState(!seed)
|
||||||
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
|
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
|
||||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||||
|
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
|
||||||
|
const nativeRequest = useRef(0)
|
||||||
|
const [dockerDiscovery, setDockerDiscovery] = useState<NativeDiscovery | null>(null)
|
||||||
|
const dockerRequest = useRef(0)
|
||||||
|
const targetRef = useRef(vmid)
|
||||||
|
targetRef.current = vmid
|
||||||
|
const targetGeneration = useRef(0)
|
||||||
|
const previousTarget = useRef(vmid)
|
||||||
|
if (previousTarget.current !== vmid) {
|
||||||
|
previousTarget.current = vmid
|
||||||
|
targetGeneration.current += 1
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
setNativeDiscovery(null)
|
||||||
|
setDockerDiscovery(null)
|
||||||
|
setEditing(null)
|
||||||
|
setDetectionNotice(null)
|
||||||
|
setSearchingApplications(false)
|
||||||
|
setSaving(false)
|
||||||
|
return () => { targetGeneration.current += 1 }
|
||||||
|
}, [vmid, managed])
|
||||||
|
useEffect(() => {
|
||||||
|
const generation = targetGeneration.current
|
||||||
|
const request = nativeRequest.current
|
||||||
|
let cancelled = false
|
||||||
|
if (managed) return
|
||||||
|
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
|
||||||
|
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
|
||||||
|
&& nativeRequest.current === request) setNativeDiscovery(result)
|
||||||
|
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [vmid, managed, cacheRevision])
|
||||||
|
useEffect(() => {
|
||||||
|
const generation = targetGeneration.current
|
||||||
|
const request = dockerRequest.current
|
||||||
|
let cancelled = false
|
||||||
|
if (managed) return
|
||||||
|
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`).then(result => {
|
||||||
|
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
|
||||||
|
&& dockerRequest.current === request) setDockerDiscovery(result)
|
||||||
|
}).catch(() => { /* optional cached result; never initiate a scan */ })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [vmid, managed])
|
||||||
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
const [searchingApplications, setSearchingApplications] = useState(false)
|
||||||
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
||||||
// Editor state
|
// Editor state
|
||||||
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
|
const [editing, setEditingState] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null)
|
||||||
|
const editingRef = useRef(editing)
|
||||||
|
editingRef.current = editing
|
||||||
|
const [iconEndpoint, setIconEndpoint] = useState("")
|
||||||
|
const [discoveringLocalLogo, setDiscoveringLocalLogo] = useState(false)
|
||||||
|
const iconRequest = useRef(0)
|
||||||
|
const iconAbort = useRef<AbortController | null>(null)
|
||||||
|
const setEditing: typeof setEditingState = value => {
|
||||||
|
// Invalidate synchronously, including before React commits a batched edit.
|
||||||
|
iconRequest.current += 1
|
||||||
|
iconAbort.current?.abort()
|
||||||
|
setDiscoveringLocalLogo(false)
|
||||||
|
setEditingState(value)
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
iconRequest.current += 1
|
||||||
|
iconAbort.current?.abort()
|
||||||
|
setDiscoveringLocalLogo(false)
|
||||||
|
return () => {
|
||||||
|
iconRequest.current += 1
|
||||||
|
iconAbort.current?.abort()
|
||||||
|
}
|
||||||
|
}, [editing, vmid, iconEndpoint])
|
||||||
const [binaryArgsInput, setBinaryArgsInput] = useState("")
|
const [binaryArgsInput, setBinaryArgsInput] = useState("")
|
||||||
const [commandArgvInput, setCommandArgvInput] = useState("")
|
const [commandArgvInput, setCommandArgvInput] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -522,6 +654,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
// `initialData` or the shared cache, the sidecar is already
|
// `initialData` or the shared cache, the sidecar is already
|
||||||
// populated and the user shouldn't see a flash of "Loading…"
|
// populated and the user shouldn't see a flash of "Loading…"
|
||||||
// while we revalidate.
|
// while we revalidate.
|
||||||
|
const generation = targetGeneration.current
|
||||||
|
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||||
if (!sidecar) setLoading(true)
|
if (!sidecar) setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -533,17 +667,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
// the "Loading applications…" flash that used to show while a
|
// the "Loading applications…" flash that used to show while a
|
||||||
// second, racing fetch caught up.
|
// second, racing fetch caught up.
|
||||||
const bundle = await fetchLxcApps(vmid)
|
const bundle = await fetchLxcApps(vmid)
|
||||||
if (bundle) {
|
if (bundle && isCurrent()) {
|
||||||
setSidecar(bundle.sidecar)
|
setSidecar(bundle.sidecar)
|
||||||
setSuggestions(bundle.suggestions)
|
setSuggestions(bundle.suggestions)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || t("vmLxc.appEditor.loadFailed"))
|
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.loadFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (isCurrent()) setLoading(false)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [vmid, managed])
|
}, [vmid, managed, cacheRevision])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (managed) return
|
if (managed) return
|
||||||
@@ -614,10 +748,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
if (lower.startsWith("registry unreachable")) {
|
if (lower.startsWith("registry unreachable")) {
|
||||||
return t("vmLxc.appEditor.upstreamErrorRegistry")
|
return t("vmLxc.appEditor.upstreamErrorRegistry")
|
||||||
}
|
}
|
||||||
return msg
|
return localizeAppEditorError(msg, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorOpen = !!editing
|
const editorOpen = !!editing && editing.targetVmid === vmid
|
||||||
|
|
||||||
// The picker catalog is only needed after the user opens the editor.
|
// The picker catalog is only needed after the user opens the editor.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -653,8 +787,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
// (`extras[]` from the backend). Both use the same DetectedApp
|
// (`extras[]` from the backend). Both use the same DetectedApp
|
||||||
// shape so the empty state renders them uniformly.
|
// shape so the empty state renders them uniformly.
|
||||||
const detectedList: DetectedApp[] = useMemo(() => {
|
const detectedList: DetectedApp[] = useMemo(() => {
|
||||||
if (!suggestions) return []
|
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : []),
|
||||||
const out: DetectedApp[] = []
|
...(dockerDiscovery?.vmid === vmid ? dockerDiscovery.candidates || [] : [])]
|
||||||
|
if (!suggestions) return out
|
||||||
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
||||||
// community script — so its template id identifies it instead.
|
// community script — so its template id identifies it instead.
|
||||||
const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id
|
const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id
|
||||||
@@ -679,12 +814,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}, [suggestions])
|
}, [suggestions, nativeDiscovery, dockerDiscovery, vmid])
|
||||||
|
|
||||||
// Registered slugs — used to filter the detection list down to
|
// Registered slugs — used to filter the detection list down to
|
||||||
// what the user hasn't already registered on this CT.
|
// what the user hasn't already registered on this CT.
|
||||||
const registeredSlugs = useMemo(
|
const registeredSlugs = useMemo(
|
||||||
() => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]),
|
() => new Set(apps.flatMap((a) => [a.helper_slug, a.discovery_id]).filter(Boolean) as string[]),
|
||||||
[apps],
|
[apps],
|
||||||
)
|
)
|
||||||
// Dismissed slugs — persisted in the sidecar. Chips the user
|
// Dismissed slugs — persisted in the sidecar. Chips the user
|
||||||
@@ -750,14 +885,44 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchInstalledApplications = async () => {
|
const searchInstalledApplications = async () => {
|
||||||
|
const generation = targetGeneration.current
|
||||||
|
const request = ++nativeRequest.current
|
||||||
|
++dockerRequest.current
|
||||||
|
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||||
|
&& nativeRequest.current === request
|
||||||
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
||||||
setSearchingApplications(true)
|
setSearchingApplications(true)
|
||||||
setDetectionNotice(null)
|
setDetectionNotice(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
let native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
if (!isCurrent()) return
|
||||||
|
setNativeDiscovery(native)
|
||||||
|
if (native.status === "error" || native.status === "busy") {
|
||||||
|
const stopped = native.status === "error" && Array.isArray(native.issues)
|
||||||
|
&& native.issues.includes("guest_stopped")
|
||||||
|
setDetectionNotice({ found: false, text: t(stopped
|
||||||
|
? "vmLxc.appEditor.nativeScanStopped" : "vmLxc.appEditor.nativeScanFailed") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
if (!isCurrent()) return
|
||||||
|
setDockerDiscovery(docker)
|
||||||
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
|
if (!isCurrent()) return
|
||||||
|
// Recompose against the refreshed catalog without another native scan.
|
||||||
|
native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
|
||||||
|
if (!isCurrent()) return
|
||||||
|
setNativeDiscovery(native)
|
||||||
|
docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`)
|
||||||
|
if (!isCurrent()) return
|
||||||
|
setDockerDiscovery(docker)
|
||||||
setSuggestions(result)
|
setSuggestions(result)
|
||||||
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
|
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
|
||||||
|
|
||||||
@@ -765,11 +930,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
if (result.helper_slug) detected.add(result.helper_slug)
|
if (result.helper_slug) detected.add(result.helper_slug)
|
||||||
for (const item of result.extras || []) detected.add(item.slug)
|
for (const item of result.extras || []) detected.add(item.slug)
|
||||||
for (const item of result.docker_workloads || []) detected.add(item.slug)
|
for (const item of result.docker_workloads || []) detected.add(item.slug)
|
||||||
|
for (const item of native.candidates) detected.add(item.slug)
|
||||||
|
for (const item of docker.candidates || []) detected.add(item.slug)
|
||||||
const visible = [...detected].filter(
|
const visible = [...detected].filter(
|
||||||
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
||||||
)
|
)
|
||||||
const newCount = visible.filter((slug) => !before.has(slug)).length
|
const newCount = visible.filter((slug) => !before.has(slug)).length
|
||||||
if (newCount === 1) {
|
if (docker.status === "error" || docker.status === "busy") {
|
||||||
|
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanFailed") })
|
||||||
|
} else if (docker.status === "partial" || docker.stale) {
|
||||||
|
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanPartial") })
|
||||||
|
} else if (native.status === "partial") {
|
||||||
|
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") })
|
||||||
|
} else if (newCount === 1) {
|
||||||
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
||||||
} else if (newCount > 1) {
|
} else if (newCount > 1) {
|
||||||
setDetectionNotice({
|
setDetectionNotice({
|
||||||
@@ -785,9 +958,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
|
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setSearchingApplications(false)
|
if (isCurrent()) setSearchingApplications(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,6 +977,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
existing?: AppEntry,
|
existing?: AppEntry,
|
||||||
opts?: { withTracking?: boolean, preset?: DetectedApp },
|
opts?: { withTracking?: boolean, preset?: DetectedApp },
|
||||||
) => {
|
) => {
|
||||||
|
const generation = targetGeneration.current
|
||||||
let seed: AppConfig
|
let seed: AppConfig
|
||||||
if (existing) {
|
if (existing) {
|
||||||
seed = {
|
seed = {
|
||||||
@@ -832,6 +1006,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
health_path: existing.health_path || "",
|
health_path: existing.health_path || "",
|
||||||
logo_url: existing.logo_url || "",
|
logo_url: existing.logo_url || "",
|
||||||
helper_slug: existing.helper_slug || "",
|
helper_slug: existing.helper_slug || "",
|
||||||
|
...(existing.discovery_id ? { discovery_id: existing.discovery_id } : {}),
|
||||||
// Preserved explicitly: saving replaces the whole record, so dropping
|
// Preserved explicitly: saving replaces the whole record, so dropping
|
||||||
// it here would silently un-delegate the app the first time someone
|
// it here would silently un-delegate the app the first time someone
|
||||||
// edits one of its ports.
|
// edits one of its ports.
|
||||||
@@ -862,7 +1037,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
const p = opts.preset
|
const p = opts.preset
|
||||||
seed.name = p.name
|
seed.name = p.name
|
||||||
seed.logo_url = p.logo_url || ""
|
seed.logo_url = p.logo_url || ""
|
||||||
seed.helper_slug = p.slug
|
if (p.discovery_id) {
|
||||||
|
seed.discovery_id = p.discovery_id
|
||||||
|
seed.update_method = "none"
|
||||||
|
seed.update_command = ""
|
||||||
|
} else seed.helper_slug = p.slug
|
||||||
// Docker endpoints come from the real published host-port mappings
|
// Docker endpoints come from the real published host-port mappings
|
||||||
// listed under Web links. Do not pre-save a catalog default such as
|
// listed under Web links. Do not pre-save a catalog default such as
|
||||||
// 9000; the user explicitly chooses which workload links to add.
|
// 9000; the user explicitly chooses which workload links to add.
|
||||||
@@ -943,9 +1122,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
setShowAdvanced(false)
|
setShowAdvanced(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (targetRef.current !== vmid || targetGeneration.current !== generation) return
|
||||||
setBinaryArgsInput((seed.binary_args || []).join(", "))
|
setBinaryArgsInput((seed.binary_args || []).join(", "))
|
||||||
setCommandArgvInput((seed.command_argv || []).join(", "))
|
setCommandArgvInput((seed.command_argv || []).join(", "))
|
||||||
setEditing({ appId: existing?.id || null, draft: seed })
|
setEditing({ appId: existing?.id || null, draft: seed, targetVmid: vmid })
|
||||||
setDetectorTest(null)
|
setDetectorTest(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
|
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
|
||||||
@@ -959,7 +1139,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!editing) return
|
if (!editing || editing.targetVmid !== vmid || targetRef.current !== vmid) return
|
||||||
|
const generation = targetGeneration.current
|
||||||
|
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -973,6 +1155,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
body: JSON.stringify(editing.draft),
|
body: JSON.stringify(editing.draft),
|
||||||
})
|
})
|
||||||
if ((r as any).error) throw new Error((r as any).error)
|
if ((r as any).error) throw new Error((r as any).error)
|
||||||
|
if (!isCurrent()) return
|
||||||
setSidecar(r)
|
setSidecar(r)
|
||||||
setLxcAppsCached(vmid, r, suggestions)
|
setLxcAppsCached(vmid, r, suggestions)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
@@ -980,9 +1163,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
setCommandArgvInput("")
|
setCommandArgvInput("")
|
||||||
onChange?.()
|
onChange?.()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
if (isCurrent()) setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,7 +1232,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeOne = async (appId: string) => {
|
const removeOne = async (appId: string) => {
|
||||||
if (!confirm("Remove this application from the CT's App tab?")) return
|
if (!confirm(t("vmLxc.appEditor.confirmRemove"))) return
|
||||||
setBusyAppId(appId)
|
setBusyAppId(appId)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -1283,7 +1466,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Editor ─────────────────────────────────────────────────────
|
// ── Editor ─────────────────────────────────────────────────────
|
||||||
if (editing) {
|
if (editorOpen && editing) {
|
||||||
const draft = editing.draft
|
const draft = editing.draft
|
||||||
const method = draft.installed_via || ""
|
const method = draft.installed_via || ""
|
||||||
const isPackaged = method === "dpkg" || method === "apk"
|
const isPackaged = method === "dpkg" || method === "apk"
|
||||||
@@ -1291,6 +1474,40 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
setDetectorTest(null)
|
setDetectorTest(null)
|
||||||
setEditing({ ...editing, draft: { ...draft, ...patch } })
|
setEditing({ ...editing, draft: { ...draft, ...patch } })
|
||||||
}
|
}
|
||||||
|
const iconPorts = draft.ports.filter(entry => typeof entry.port === "number" && entry.port > 0 && entry.port <= 65535)
|
||||||
|
const endpointKey = (entry: PortEntry) => `${entry.scheme || defaultSchemeFor(entry.port)}:${entry.port}`
|
||||||
|
const selectedIconPort = iconPorts.find(entry => endpointKey(entry) === iconEndpoint) || iconPorts[0]
|
||||||
|
const discoverLocalLogo = async () => {
|
||||||
|
if (!selectedIconPort || discoveringLocalLogo) return
|
||||||
|
const snapshot = editing
|
||||||
|
const generation = targetGeneration.current
|
||||||
|
const requestId = ++iconRequest.current
|
||||||
|
const controller = new AbortController()
|
||||||
|
iconAbort.current = controller
|
||||||
|
const current = () => iconRequest.current === requestId && editingRef.current === snapshot
|
||||||
|
&& targetRef.current === vmid && targetGeneration.current === generation
|
||||||
|
setDiscoveringLocalLogo(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await fetchApi<{ logo_url: string }>(`/api/vms/${vmid}/apps/logo-discovery`, {
|
||||||
|
method: "POST", signal: controller.signal,
|
||||||
|
body: JSON.stringify({ port: selectedIconPort.port, scheme: selectedIconPort.scheme || defaultSchemeFor(selectedIconPort.port) }),
|
||||||
|
})
|
||||||
|
if (!current()) return
|
||||||
|
if (!LOCAL_LOGO_CACHE_PATH.test(result.logo_url) || !result.logo_url.startsWith(`/api/vms/${vmid}/`)) {
|
||||||
|
throw new Error("invalid icon response")
|
||||||
|
}
|
||||||
|
setEditing(previous => previous === snapshot
|
||||||
|
? { ...previous, draft: { ...previous.draft, logo_url: result.logo_url } } : previous)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (current()) {
|
||||||
|
const code = (error as { body?: { code?: string } })?.body?.code
|
||||||
|
setError(t(code === "local_logo_stopped" ? "vmLxc.appEditor.localLogoStopped" : "vmLxc.appEditor.localLogoUnavailable"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current()) setDiscoveringLocalLogo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Editing the Name auto-fills the Package field on packaged
|
// Editing the Name auto-fills the Package field on packaged
|
||||||
// methods when it's still empty. Rationale: 90% of the time the
|
// methods when it's still empty. Rationale: 90% of the time the
|
||||||
// dpkg/apk package name mirrors the friendly app name (jellyfin,
|
// dpkg/apk package name mirrors the friendly app name (jellyfin,
|
||||||
@@ -1523,18 +1740,47 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
from https://selfh.st/icons); empty → no logo in the
|
from https://selfh.st/icons); empty → no logo in the
|
||||||
app card header. */}
|
app card header. */}
|
||||||
<div className="pt-2 border-t border-border/50">
|
<div className="pt-2 border-t border-border/50">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<Label htmlFor="app-logo" className="text-xs uppercase tracking-wider text-muted-foreground">
|
<Label htmlFor="app-logo" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
|
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm"
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
disabled={!selectedIconPort || discoveringLocalLogo || saving}
|
||||||
|
onClick={discoverLocalLogo}>
|
||||||
|
{discoveringLocalLogo ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Search className="h-3.5 w-3.5 mr-1" />}
|
||||||
|
{t("vmLxc.appEditor.discoverLocalLogo")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<Input
|
<Input
|
||||||
id="app-logo"
|
id="app-logo"
|
||||||
type="url"
|
type="text"
|
||||||
value={draft.logo_url || ""}
|
value={draft.logo_url || ""}
|
||||||
onChange={(e) => setField({ logo_url: e.target.value })}
|
onChange={(e) => setField({ logo_url: e.target.value })}
|
||||||
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
|
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
|
||||||
maxLength={512}
|
maxLength={512}
|
||||||
className="text-sm mt-2 font-mono"
|
className="text-sm font-mono"
|
||||||
/>
|
/>
|
||||||
|
{draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) &&
|
||||||
|
<ThemeAwareLogo src={draft.logo_url} className="h-8 w-8 shrink-0 object-contain" />}
|
||||||
|
</div>
|
||||||
|
{iconPorts.length > 1 && <select
|
||||||
|
aria-label={t("vmLxc.appEditor.localLogoPort")}
|
||||||
|
value={selectedIconPort ? endpointKey(selectedIconPort) : ""}
|
||||||
|
onChange={event => {
|
||||||
|
iconRequest.current += 1
|
||||||
|
iconAbort.current?.abort()
|
||||||
|
setDiscoveringLocalLogo(false)
|
||||||
|
setIconEndpoint(event.target.value)
|
||||||
|
}}
|
||||||
|
className="mt-2 rounded border bg-background px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{iconPorts.map(entry => <option key={endpointKey(entry)} value={endpointKey(entry)}>
|
||||||
|
{endpointKey(entry)}
|
||||||
|
</option>)}
|
||||||
|
</select>}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{t("vmLxc.appEditor.localLogoHelp")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Web links — each port becomes a clickable link (built as
|
{/* Web links — each port becomes a clickable link (built as
|
||||||
@@ -1843,14 +2089,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
id="app-package"
|
id="app-package"
|
||||||
value={draft.package || ""}
|
value={draft.package || ""}
|
||||||
onChange={(e) => setField({ package: e.target.value })}
|
onChange={(e) => setField({ package: e.target.value })}
|
||||||
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
|
placeholder={t("vmLxc.appEditor.packageExample", { package: method === "dpkg" ? "jellyfin-server" : "tailscale" })}
|
||||||
/>
|
/>
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
Auto-filled from Name. Verify with{" "}
|
{t("vmLxc.appEditor.packageHelpBefore")}{" "}
|
||||||
<code className="text-foreground/70">
|
<code className="text-foreground/70">
|
||||||
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
|
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
inside the CT.
|
{t("vmLxc.appEditor.packageHelpAfter")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1866,8 +2112,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
Absolute path. Find it with{" "}
|
{t("vmLxc.appEditor.binaryHelpBefore")}{" "}
|
||||||
<code className="text-foreground/70">which <app></code> or{" "}
|
<code className="text-foreground/70">which <app></code>{" "}{t("vmLxc.appEditor.binaryHelpOr")}{" "}
|
||||||
<code className="text-foreground/70">systemctl show <service> -p ExecStart</code>.
|
<code className="text-foreground/70">systemctl show <service> -p ExecStart</code>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2332,7 +2578,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
{detectorTest.installed.version ? (
|
{detectorTest.installed.version ? (
|
||||||
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
|
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.installed.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
|
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
|
||||||
{detectorTest.installed.method || "—"}
|
{detectorTest.installed.method || "—"}
|
||||||
@@ -2348,7 +2594,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
) : detectorTest.upstream.version ? (
|
) : detectorTest.upstream.version ? (
|
||||||
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
|
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.upstream.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||||
)}
|
)}
|
||||||
{detectorTest.upstream.type && (
|
{detectorTest.upstream.type && (
|
||||||
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
|
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
|
||||||
@@ -2361,7 +2607,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||||
<span>{error}</span>
|
<span>{localizeAppEditorError(error, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2452,6 +2698,31 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
||||||
: t("vmLxc.appEditor.detectedInContainer")}
|
: t("vmLxc.appEditor.detectedInContainer")}
|
||||||
</div>
|
</div>
|
||||||
|
{(d.source === "native_metadata" || d.source === "docker_metadata") && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-1 break-all">
|
||||||
|
<div>{d.installed_version
|
||||||
|
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
|
||||||
|
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
|
||||||
|
{(d.source === "docker_metadata"
|
||||||
|
? dockerDiscovery?.stale || dockerDiscovery?.status === "partial"
|
||||||
|
: nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
|
||||||
|
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
|
||||||
|
)}
|
||||||
|
{d.evidence?.map((e, index) => <div key={index}>
|
||||||
|
{t(e.kind === "docker_container" ? "vmLxc.appEditor.dockerEvidenceContainer"
|
||||||
|
: e.kind === "docker_image" ? "vmLxc.appEditor.dockerEvidenceImage"
|
||||||
|
: e.kind === "compose_project" ? "vmLxc.appEditor.dockerEvidenceProject"
|
||||||
|
: e.kind === "tcp_listener" ? (d.source === "docker_metadata"
|
||||||
|
? "vmLxc.appEditor.dockerEvidencePort" : "vmLxc.appEditor.nativeEvidencePort")
|
||||||
|
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
|
||||||
|
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
|
||||||
|
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
|
||||||
|
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
|
||||||
|
</div>)}
|
||||||
|
<div>{t(d.source === "docker_metadata"
|
||||||
|
? "vmLxc.appEditor.dockerEndpointReview" : "vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
||||||
<div className="text-[10px] text-amber-400/90 mt-0.5">
|
<div className="text-[10px] text-amber-400/90 mt-0.5">
|
||||||
{t("vmLxc.appEditor.legacyDetectorUsed")}
|
{t("vmLxc.appEditor.legacyDetectorUsed")}
|
||||||
@@ -3092,7 +3363,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||||
<span>{error}</span>
|
<span>{localizeAppEditorError(error, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -382,6 +382,19 @@ export function LxcTerminalModal({
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The host prompt came back: pct enter could not enter the
|
||||||
|
// container. Show why and close, so the session is never left
|
||||||
|
// on the host shell under the container's name.
|
||||||
|
const reason = afterPctEnter.split(/\r?\n/).slice(0, -1)
|
||||||
|
.map((line) => line.trim()).filter(Boolean).join("\r\n")
|
||||||
|
if (reason) term.writeln(`\x1b[31m${reason}\x1b[0m`)
|
||||||
|
term.writeln(`\r\n\x1b[33m${/Failed to exec/.test(reason)
|
||||||
|
? t("lxcTerminal.errors.noShell")
|
||||||
|
: t("lxcTerminal.errors.enterFailed")}\x1b[0m`)
|
||||||
|
isInsideLxcRef.current = true
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -688,6 +701,10 @@ export function LxcTerminalModal({
|
|||||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => sendKey("\x04")}>
|
||||||
|
<span className="font-mono text-xs mr-2">Ctrl+D</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||||
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { viewedUpdateJobs, type LxcUpdateJob } from "../lib/lxc-update-jobs"
|
||||||
|
|
||||||
|
export function LxcUpdateJobs({ activeRunId, onResume }: {
|
||||||
|
activeRunId?: string
|
||||||
|
onResume: (job: LxcUpdateJob) => void
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const [jobs, setJobs] = useState<LxcUpdateJob[]>([])
|
||||||
|
const [seen, setSeen] = useState<string[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
let closed = false
|
||||||
|
const read = () => {
|
||||||
|
void fetchApi<{ jobs: LxcUpdateJob[] }>("/api/lxc-update-jobs")
|
||||||
|
.then((data) => { if (!closed) setJobs(Array.isArray(data.jobs) ? data.jobs : []) })
|
||||||
|
.catch(() => {}) // Auth recovery belongs to fetchApi, never to a job retry.
|
||||||
|
}
|
||||||
|
const viewed = () => setSeen(viewedUpdateJobs())
|
||||||
|
viewed()
|
||||||
|
read()
|
||||||
|
const timer = setInterval(read, 5000)
|
||||||
|
window.addEventListener("proxmenux-update-jobs-viewed", viewed)
|
||||||
|
return () => {
|
||||||
|
closed = true
|
||||||
|
clearInterval(timer)
|
||||||
|
window.removeEventListener("proxmenux-update-jobs-viewed", viewed)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
const visible = jobs.filter((job) => job.id !== activeRunId
|
||||||
|
&& (job.state !== "complete" || !seen.includes(job.id)))
|
||||||
|
if (!visible.length) return null
|
||||||
|
return <div className="space-y-2" role="status">
|
||||||
|
{visible.map((job) => <div key={job.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
|
||||||
|
<p className="text-sm">{t(job.state === "complete"
|
||||||
|
? "vmLxc.updates.backgroundFinished" : "vmLxc.updates.backgroundRunning",
|
||||||
|
{ vmid: job.vmid, name: job.ct_name || `CT-${job.vmid}` })}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onResume(job)}>
|
||||||
|
{t("vmLxc.updates.viewBackgroundJob")}
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import useSWR, { mutate as refreshCache } from "swr"
|
||||||
|
import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Globe, Pencil, Plus, Server, Trash2 } from "lucide-react"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { Input } from "./ui/input"
|
||||||
|
import { Label } from "./ui/label"
|
||||||
|
import { Switch } from "./ui/switch"
|
||||||
|
import { Checkbox } from "./ui/checkbox"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
} from "./ui/dropdown-menu"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { monitorLinkError } from "../lib/monitor-link-errors"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
interface MonitorLink {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
product: "pve" | "pbs"
|
||||||
|
group: "cluster" | "standalone" | "pbs"
|
||||||
|
url: string | null
|
||||||
|
status: "online" | "offline" | "unknown" | "local"
|
||||||
|
native_url?: string | null
|
||||||
|
manual: boolean
|
||||||
|
// A PBS installed on this same host.
|
||||||
|
local?: boolean
|
||||||
|
hidden: boolean
|
||||||
|
custom_url: boolean
|
||||||
|
}
|
||||||
|
interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] }
|
||||||
|
|
||||||
|
const GROUPS = ["cluster", "standalone", "pbs"] as const
|
||||||
|
const fetcher = (url: string) => fetchApi<MonitorLinks>(url)
|
||||||
|
const ORIGIN_KEY = "proxmenux-came-from"
|
||||||
|
const DECLINED_KEY = "proxmenux-links-declined"
|
||||||
|
const MAX_CARRIED = 16
|
||||||
|
|
||||||
|
interface Carried { url: string; name: string; product: "pve" | "pbs" }
|
||||||
|
interface Origin extends Carried { links?: Carried[] }
|
||||||
|
|
||||||
|
// A Monitor address is accepted only as a bare http(s) origin.
|
||||||
|
function bareOrigin(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value)
|
||||||
|
return /^https?:$/.test(parsed.protocol) && parsed.origin === value ? parsed.origin : null
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// localhost names the computer that opens the address, never another node.
|
||||||
|
function isLoopback(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
||||||
|
return host === "localhost" || host.endsWith(".localhost") || /^127\./.test(host)
|
||||||
|
|| host === "::1" || /^::ffff:127\./.test(host) || /^::ffff:7f/.test(host)
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Monitors the source already links, carried in the jump so this one can add them too.
|
||||||
|
function readCarried(raw: string | null): Carried[] {
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const rows = JSON.parse(decodeURIComponent(escape(atob(raw.replace(/-/g, "+").replace(/_/g, "/")))))
|
||||||
|
if (!Array.isArray(rows)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: Carried[] = []
|
||||||
|
for (const row of rows.slice(0, MAX_CARRIED)) {
|
||||||
|
const url = bareOrigin(row?.u)
|
||||||
|
if (!url || url === window.location.origin || isLoopback(url) || seen.has(url)) continue
|
||||||
|
seen.add(url)
|
||||||
|
out.push({ url, name: String(row?.n || new URL(url).hostname).slice(0, 64), product: row?.p === "pbs" ? "pbs" : "pve" })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCarried(rows: Carried[]): string {
|
||||||
|
const json = JSON.stringify(rows.slice(0, MAX_CARRIED).map(row => ({ n: row.name, u: row.url, p: row.product })))
|
||||||
|
return btoa(unescape(encodeURIComponent(json))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Monitor a jump came from, as the browser reached it. Only a bare http(s)
|
||||||
|
// origin is accepted, and the parameters leave the address at once.
|
||||||
|
function takeOrigin(): (Origin & { fresh?: boolean }) | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const from = params.get("pmx_from")
|
||||||
|
if (from !== null) {
|
||||||
|
const name = params.get("pmx_name") || ""
|
||||||
|
const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve"
|
||||||
|
const links = readCarried(params.get("pmx_links"))
|
||||||
|
for (const key of ["pmx_from", "pmx_name", "pmx_product", "pmx_links"]) params.delete(key)
|
||||||
|
const query = params.toString()
|
||||||
|
window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash)
|
||||||
|
try {
|
||||||
|
const parsed = new URL(from)
|
||||||
|
if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) {
|
||||||
|
const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product,
|
||||||
|
links: links.filter(link => link.url !== parsed.origin) }
|
||||||
|
try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ }
|
||||||
|
return { ...origin, fresh: true }
|
||||||
|
}
|
||||||
|
} catch { /* not an address */ }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null")
|
||||||
|
return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
|
||||||
|
function destination(link: MonitorLink): { href: string; native: boolean } | null {
|
||||||
|
if (link.local && !link.url) return { href: `https://${window.location.hostname}:8007`, native: true }
|
||||||
|
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
|
||||||
|
return link.url ? { href: link.url, native: false } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductIcon({ product, className }: { product: string; className?: string }) {
|
||||||
|
return product === "pbs" ? <DatabaseBackup className={className} /> : <Server className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) {
|
||||||
|
const t = useT()
|
||||||
|
const [manageOpen, setManageOpen] = useState(false)
|
||||||
|
// The backend keeps the list ready, so the menu opens already filled.
|
||||||
|
const { data, error, isLoading } = useSWR<MonitorLinks>("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 })
|
||||||
|
|
||||||
|
const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null)
|
||||||
|
const [offerOrigin, setOfferOrigin] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
|
const [offered, setOffered] = useState(false)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [addError, setAddError] = useState("")
|
||||||
|
useEffect(() => { setOrigin(takeOrigin()) }, [])
|
||||||
|
const known = (url: string) => !!data?.links.some(link => link.url === url)
|
||||||
|
const originKnown = !!origin && known(origin.url)
|
||||||
|
// The source Monitor and the Monitors it links that this one does not know yet.
|
||||||
|
const candidates: Carried[] = origin && data
|
||||||
|
? [origin, ...(origin.links || [])].filter(row => !known(row.url) && !isLoopback(row.url))
|
||||||
|
: []
|
||||||
|
const declined = (url: string) => {
|
||||||
|
try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false }
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
// Offered right after arriving, until every node the source links is known here.
|
||||||
|
if (!offered && origin?.fresh && data && candidates.length > 0 && !declined(origin.url)) {
|
||||||
|
setOffered(true)
|
||||||
|
setSelected(candidates.map(row => row.url))
|
||||||
|
setOfferOrigin(true)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [origin, data])
|
||||||
|
|
||||||
|
// The address carries where the jump comes from and the Monitors this one links,
|
||||||
|
// so the other Monitor can offer the way back and the rest of the nodes.
|
||||||
|
const withOrigin = (href: string) => {
|
||||||
|
const target = bareOrigin(href.replace(/\/+$/, ""))
|
||||||
|
const carried = (data?.links || [])
|
||||||
|
.filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target
|
||||||
|
&& !isLoopback(link.url))
|
||||||
|
.map(link => ({ url: link.url as string, name: link.name, product: link.product }))
|
||||||
|
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
|
||||||
|
if (carried.length > 0) params.set("pmx_links", writeCarried(carried))
|
||||||
|
return `${href}/?${params.toString()}`
|
||||||
|
}
|
||||||
|
const open = (href: string, newTab: boolean) => {
|
||||||
|
if (newTab) window.open(href, "_blank", "noopener,noreferrer")
|
||||||
|
else window.location.assign(href)
|
||||||
|
}
|
||||||
|
const go = (link: MonitorLink, newTab: boolean) => {
|
||||||
|
const target = destination(link)
|
||||||
|
if (!target) return
|
||||||
|
if (target.native) open(target.href, true)
|
||||||
|
else open(withOrigin(target.href), newTab)
|
||||||
|
}
|
||||||
|
const addSelected = async () => {
|
||||||
|
setAdding(true); setAddError("")
|
||||||
|
try {
|
||||||
|
for (const row of candidates.filter(row => selected.includes(row.url))) {
|
||||||
|
await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: row.name, url: row.url, product: row.product }) })
|
||||||
|
}
|
||||||
|
await refreshCache("/api/monitor-links")
|
||||||
|
setOfferOrigin(false)
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(monitorLinkError(err, t))
|
||||||
|
try { await refreshCache("/api/monitor-links") }
|
||||||
|
catch { /* SWR shows the load error; keep the localized mutation error. */ }
|
||||||
|
} finally { setAdding(false) }
|
||||||
|
}
|
||||||
|
const notNow = () => {
|
||||||
|
if (origin) {
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]
|
||||||
|
sessionStorage.setItem(DECLINED_KEY, JSON.stringify([...list.filter(url => url !== origin.url), origin.url]))
|
||||||
|
} catch { /* private mode */ }
|
||||||
|
}
|
||||||
|
setOfferOrigin(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div onClick={event => event.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className={`gap-2 border-border bg-transparent hover:bg-secondary ${compact ? "h-8 px-2.5 text-xs" : ""}`}>
|
||||||
|
<Server className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
|
||||||
|
<span className="max-w-[12rem] truncate">{t("status.node", { node: nodeName })}</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-72">
|
||||||
|
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t("monitorLinks.thisNode")}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem disabled className="gap-2 opacity-100">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||||
|
<ProductIcon product={data?.self.product || ""} className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="truncate font-medium">{nodeName}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{origin && !originKnown && <DropdownMenuItem className="gap-2" onSelect={() => open(withOrigin(origin.url), false)}>
|
||||||
|
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{t("monitorLinks.backTo", { name: origin.name })}</span>
|
||||||
|
</DropdownMenuItem>}
|
||||||
|
{isLoading && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||||
|
{error && <p className="px-2 py-1.5 text-xs text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||||
|
{data && data.links.length === 0 && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||||
|
{data && GROUPS.map(group => {
|
||||||
|
const rows = data.links.filter(link => link.group === group)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return <div key={group}>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
|
||||||
|
{rows.map(link => {
|
||||||
|
const target = destination(link)
|
||||||
|
// A PBS without a Monitor opens its own web interface, not another Monitor.
|
||||||
|
if (target?.native) return <DropdownMenuItem key={link.key} className="gap-2" onSelect={() => go(link, false)}>
|
||||||
|
<span className="h-2 w-2 shrink-0" />
|
||||||
|
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
||||||
|
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
|
||||||
|
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
|
||||||
|
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||||
|
{link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
||||||
|
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
|
||||||
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</button>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="gap-2" onSelect={() => setManageOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />{t("monitorLinks.manage")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||||
|
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
|
||||||
|
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-72 space-y-2 overflow-y-auto">
|
||||||
|
{candidates.map(row => <label key={row.url} className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-card px-3 py-2">
|
||||||
|
<Checkbox className="mt-0.5" checked={selected.includes(row.url)} disabled={adding}
|
||||||
|
onCheckedChange={checked => setSelected(current => checked === true ? [...current, row.url] : current.filter(url => url !== row.url))} />
|
||||||
|
<ProductIcon product={row.product} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium">{row.name}</span>
|
||||||
|
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
|
||||||
|
</span>
|
||||||
|
</label>)}
|
||||||
|
</div>
|
||||||
|
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" disabled={adding} onClick={notNow}>{t("monitorLinks.notNow")}</Button>
|
||||||
|
<Button className="bg-blue-600 text-white hover:bg-blue-700" disabled={adding || selected.length === 0} onClick={addSelected}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />{t("monitorLinks.addSelected")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const t = useT()
|
||||||
|
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||||
|
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||||
|
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||||
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<unknown>) => {
|
||||||
|
setBusy(true); setError("")
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
await mutate()
|
||||||
|
await refreshCache("/api/monitor-links")
|
||||||
|
} catch (err) {
|
||||||
|
setError(monitorLinkError(err, t))
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const send = (path: string, method: string, body?: unknown) =>
|
||||||
|
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
||||||
|
|
||||||
|
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||||
|
onClick={event => event.stopPropagation()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||||
|
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||||
|
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="monitor-link-url">{t("monitorLinks.address")}</Label>
|
||||||
|
<Input id="monitor-link-url" value={form.url} disabled={busy} placeholder="https://192.168.1.20:8008"
|
||||||
|
onChange={event => setForm({ ...form, url: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="monitor-link-product">{t("monitorLinks.type")}</Label>
|
||||||
|
<Select value={form.product} disabled={busy} onValueChange={product => setForm({ ...form, product })}>
|
||||||
|
<SelectTrigger id="monitor-link-product" className="w-full sm:w-56"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pve">Proxmox VE</SelectItem>
|
||||||
|
<SelectItem value="pbs">Proxmox Backup Server</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
||||||
|
await send("/api/monitor-links", "POST", form)
|
||||||
|
setForm({ name: "", url: "", product: "pve" })
|
||||||
|
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||||
|
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||||
|
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||||
|
{data?.links.map(link => {
|
||||||
|
const own = link.manual || link.custom_url
|
||||||
|
const shown = link.url || ""
|
||||||
|
const draft = addresses[link.key] ?? (own ? shown : "")
|
||||||
|
const isEditing = editing === link.key
|
||||||
|
// A read-only card is a panel; while its address is edited it lets the field stand out.
|
||||||
|
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${isEditing ? "" : "bg-card"} ${link.hidden ? "opacity-60" : ""}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")}</span>
|
||||||
|
{!link.manual && <label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{t("monitorLinks.visible")}
|
||||||
|
<Switch checked={!link.hidden} disabled={busy} className="data-[state=checked]:bg-blue-600"
|
||||||
|
onCheckedChange={checked => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { hidden: !checked }))} />
|
||||||
|
</label>}
|
||||||
|
{link.manual && <Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.remove")}
|
||||||
|
onClick={() => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "DELETE"))}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>}
|
||||||
|
</div>
|
||||||
|
{link.local && !link.custom_url
|
||||||
|
? <div className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
||||||
|
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||||
|
</div>
|
||||||
|
: isEditing
|
||||||
|
? <div className="grid grid-cols-2 gap-2 sm:flex">
|
||||||
|
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||||
|
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||||
|
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||||
|
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||||
|
disabled={busy || addresses[link.key] === undefined || (link.manual && !draft.trim())}
|
||||||
|
onClick={() => run(async () => {
|
||||||
|
await send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { url: addresses[link.key] })
|
||||||
|
setAddresses(({ [link.key]: _, ...rest }) => rest)
|
||||||
|
setEditing(null)
|
||||||
|
})}>{t("monitorLinks.save")}</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-10" disabled={busy}
|
||||||
|
onClick={() => { setAddresses(({ [link.key]: _, ...rest }) => rest); setEditing(null) }}>{t("monitorLinks.cancel")}</Button>
|
||||||
|
</div>
|
||||||
|
: <div className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-sm text-foreground">{shown || "—"}</span>
|
||||||
|
{!link.manual && !link.custom_url && <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.autoAddress")}</span>}
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.changeAddress")} title={t("monitorLinks.changeAddress")}
|
||||||
|
onClick={() => setEditing(link.key)}>
|
||||||
|
<Pencil className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ type Bridge = {
|
|||||||
type Guest = {
|
type Guest = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
kind: "lxc" | "vm" | "host"
|
kind: "lxc" | "vm" | "host" | "interface"
|
||||||
bridge: string
|
bridge: string
|
||||||
rx: number
|
rx: number
|
||||||
tx: number
|
tx: number
|
||||||
@@ -87,6 +87,8 @@ const ICONS: Record<string, string> = {
|
|||||||
vm: `<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>`,
|
vm: `<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ICONS.interface = ICONS.bridge
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
host: "var(--amber-500, #f59e0b)",
|
host: "var(--amber-500, #f59e0b)",
|
||||||
lxc: "var(--cyan-500, #06b6d4)",
|
lxc: "var(--cyan-500, #06b6d4)",
|
||||||
@@ -97,6 +99,7 @@ const COLORS = {
|
|||||||
// just another NIC.
|
// just another NIC.
|
||||||
bond: "var(--orange-500, #f97316)",
|
bond: "var(--orange-500, #f97316)",
|
||||||
bridge: "var(--cyan-500, #06b6d4)",
|
bridge: "var(--cyan-500, #06b6d4)",
|
||||||
|
interface: "var(--cyan-500, #06b6d4)",
|
||||||
gray: "#525252",
|
gray: "#525252",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ interface NetworkInterface {
|
|||||||
// Master device resolved from /sys/class/net/<iface>/master — the
|
// Master device resolved from /sys/class/net/<iface>/master — the
|
||||||
// bridge for a guest tap, the bond for a slave NIC.
|
// bridge for a guest tap, the bond for a slave NIC.
|
||||||
bridge_owner?: string
|
bridge_owner?: string
|
||||||
|
network_backend?: "ovs"
|
||||||
|
ovs_tag?: number | null
|
||||||
|
ovs_trunks?: number[]
|
||||||
bridge_members?: string[]
|
bridge_members?: string[]
|
||||||
bridge_physical_interface?: string
|
bridge_physical_interface?: string
|
||||||
bridge_bond_slaves?: string[]
|
bridge_bond_slaves?: string[]
|
||||||
@@ -568,6 +571,7 @@ export function NetworkMetrics() {
|
|||||||
...(networkData.bond_interfaces || []),
|
...(networkData.bond_interfaces || []),
|
||||||
...(networkData.bridge_interfaces || []),
|
...(networkData.bridge_interfaces || []),
|
||||||
...(networkData.vm_lxc_interfaces || []),
|
...(networkData.vm_lxc_interfaces || []),
|
||||||
|
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||||
]
|
]
|
||||||
|
|
||||||
const vmLxcInterfaces = (networkData.vm_lxc_interfaces || []).sort((a, b) => {
|
const vmLxcInterfaces = (networkData.vm_lxc_interfaces || []).sort((a, b) => {
|
||||||
@@ -867,6 +871,7 @@ export function NetworkMetrics() {
|
|||||||
...(networkData.bond_interfaces || []),
|
...(networkData.bond_interfaces || []),
|
||||||
...(networkData.bridge_interfaces || []),
|
...(networkData.bridge_interfaces || []),
|
||||||
...(networkData.vm_lxc_interfaces || []),
|
...(networkData.vm_lxc_interfaces || []),
|
||||||
|
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||||
]
|
]
|
||||||
const flowData: NetworkFlowData = {
|
const flowData: NetworkFlowData = {
|
||||||
nics: (networkData.physical_interfaces || []).map((p) => ({
|
nics: (networkData.physical_interfaces || []).map((p) => ({
|
||||||
@@ -916,7 +921,9 @@ export function NetworkMetrics() {
|
|||||||
tx: runningGuests.reduce((a, v) => a + toMBps(v.tx_Bps), 0),
|
tx: runningGuests.reduce((a, v) => a + toMBps(v.tx_Bps), 0),
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
...(networkData.vm_lxc_interfaces || []).map((v) => {
|
...[...(networkData.vm_lxc_interfaces || []),
|
||||||
|
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||||
|
].map((v) => {
|
||||||
// Authoritative bridge from the kernel (read by the
|
// Authoritative bridge from the kernel (read by the
|
||||||
// backend from /sys/class/net/<iface>/master). Fallback
|
// backend from /sys/class/net/<iface>/master). Fallback
|
||||||
// to bridge_members scan, then first bridge as last
|
// to bridge_members scan, then first bridge as last
|
||||||
@@ -929,8 +936,8 @@ export function NetworkMetrics() {
|
|||||||
(networkData.bridge_interfaces?.[0]?.name || "")
|
(networkData.bridge_interfaces?.[0]?.name || "")
|
||||||
return {
|
return {
|
||||||
id: v.name,
|
id: v.name,
|
||||||
label: v.vm_name || v.name,
|
label: v.vm_name || (v.ovs_tag != null ? `${v.name} · VLAN ${v.ovs_tag}` : v.name),
|
||||||
kind: (v.vm_type === "vm" ? "vm" : "lxc") as "vm" | "lxc",
|
kind: v.type === "vlan" ? "interface" as const : (v.vm_type === "vm" ? "vm" as const : "lxc" as const),
|
||||||
bridge: ownerName,
|
bridge: ownerName,
|
||||||
rx: toMBps(v.rx_Bps),
|
rx: toMBps(v.rx_Bps),
|
||||||
tx: toMBps(v.tx_Bps),
|
tx: toMBps(v.tx_Bps),
|
||||||
@@ -1213,6 +1220,7 @@ export function NetworkMetrics() {
|
|||||||
...(modalNetworkData.bond_interfaces || []),
|
...(modalNetworkData.bond_interfaces || []),
|
||||||
...(modalNetworkData.bridge_interfaces || []),
|
...(modalNetworkData.bridge_interfaces || []),
|
||||||
...(modalNetworkData.vm_lxc_interfaces || []),
|
...(modalNetworkData.vm_lxc_interfaces || []),
|
||||||
|
...(modalNetworkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||||
].find((iface) => iface.name === selectedInterface.name)
|
].find((iface) => iface.name === selectedInterface.name)
|
||||||
: selectedInterface
|
: selectedInterface
|
||||||
|
|
||||||
@@ -1298,7 +1306,7 @@ export function NetworkMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">MTU</div>
|
<div className="text-sm text-muted-foreground">MTU</div>
|
||||||
<div className="font-medium">{displayInterface.mtu}</div>
|
<div className="font-medium">{displayInterface.mtu || t("common.notAvailable")}</div>
|
||||||
</div>
|
</div>
|
||||||
{displayInterface.mac_address && (
|
{displayInterface.mac_address && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ export function NetworkTrafficChart({
|
|||||||
<>
|
<>
|
||||||
<MetricsCacheNotice lastChecked={staleAt} />
|
<MetricsCacheNotice lastChecked={staleAt} />
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80, left: 10 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ export function NodeMetricsCharts() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 md:px-6">
|
<CardContent className="px-0 md:px-6">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
<AreaChart data={data} margin={{ bottom: 60, left: 10, right: 10 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
@@ -509,7 +509,7 @@ export function NodeMetricsCharts() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 pr-2 md:px-6">
|
<CardContent className="px-0 pr-2 md:px-6">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
<AreaChart data={data} margin={{ bottom: 60, left: 10, right: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { AppriseServiceHelp } from "./apprise-service-help"
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
@@ -12,13 +13,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { useIsPbs } from "../lib/use-platform"
|
||||||
import {
|
import {
|
||||||
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
||||||
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
||||||
Trash2, ChevronRight, TestTube2, Mail, Webhook,
|
Trash2, ChevronRight, TestTube2, Mail, Webhook,
|
||||||
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
|
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
|
||||||
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
|
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
|
||||||
Moon, Newspaper
|
Moon, Newspaper, History, ChevronDown
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
interface ChannelConfig {
|
interface ChannelConfig {
|
||||||
@@ -109,7 +111,8 @@ interface HistoryEntry {
|
|||||||
title: string
|
title: string
|
||||||
severity: string
|
severity: string
|
||||||
sent_at: string
|
sent_at: string
|
||||||
success: boolean
|
success: boolean | number
|
||||||
|
message?: string | null
|
||||||
error_message: string | null
|
error_message: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +155,8 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
|
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
|
||||||
|
// Guests, vzdump backups and the cluster do not exist on Proxmox Backup Server.
|
||||||
|
const PVE_ONLY_CATEGORIES = new Set(["vm_ct", "backup", "cluster"])
|
||||||
|
|
||||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
||||||
|
|
||||||
@@ -328,10 +333,12 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
|||||||
|
|
||||||
export function NotificationSettings() {
|
export function NotificationSettings() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
|
const isPbs = useIsPbs()
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
|
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
|
||||||
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
const [history, setHistory] = useState<HistoryEntry[] | null>(null)
|
||||||
|
const [historyError, setHistoryError] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
@@ -452,12 +459,12 @@ export function NotificationSettings() {
|
|||||||
|
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20")
|
const data = await fetchApi<{ history?: HistoryEntry[]; error?: string }>("/api/notifications/history?limit=20")
|
||||||
if (data.success) {
|
if (data.error || !Array.isArray(data.history)) throw new Error("history unavailable")
|
||||||
setHistory(data.history || [])
|
setHistory(data.history)
|
||||||
}
|
setHistoryError(false)
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore
|
setHistoryError(true)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -700,7 +707,7 @@ export function NotificationSettings() {
|
|||||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.notificationCategories")}</Label>
|
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.notificationCategories")}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
|
{EVENT_CATEGORIES.filter(cat => cat.key !== "other" && !(isPbs && PVE_ONLY_CATEGORIES.has(cat.key))).map(cat => {
|
||||||
const isEnabled = overrides.categories[cat.key] ?? true
|
const isEnabled = overrides.categories[cat.key] ?? true
|
||||||
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
|
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
|
||||||
// Hide the LXC update toggle when the user has disabled the
|
// Hide the LXC update toggle when the user has disabled the
|
||||||
@@ -867,7 +874,7 @@ export function NotificationSettings() {
|
|||||||
// Flatten per-provider API keys
|
// Flatten per-provider API keys
|
||||||
if (cfg.ai_api_keys) {
|
if (cfg.ai_api_keys) {
|
||||||
for (const [provider, key] of Object.entries(cfg.ai_api_keys)) {
|
for (const [provider, key] of Object.entries(cfg.ai_api_keys)) {
|
||||||
if (key) {
|
if (key || provider === "ollama") {
|
||||||
flat[`ai_api_key_${provider}`] = key
|
flat[`ai_api_key_${provider}`] = key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -919,7 +926,7 @@ export function NotificationSettings() {
|
|||||||
const wasEnabled = originalConfig.enabled
|
const wasEnabled = originalConfig.enabled
|
||||||
const isNowDisabled = !nextConfig.enabled
|
const isNowDisabled = !nextConfig.enabled
|
||||||
|
|
||||||
if (wasEnabled && isNowDisabled) {
|
if (wasEnabled && isNowDisabled && !isPbs) {
|
||||||
try {
|
try {
|
||||||
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
|
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1097,15 +1104,6 @@ export function NotificationSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClearHistory = async () => {
|
|
||||||
try {
|
|
||||||
await fetchApi("/api/notifications/history", { method: "DELETE" })
|
|
||||||
setHistory([])
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maps each eye-button local key to the body shape the backend
|
// Maps each eye-button local key to the body shape the backend
|
||||||
// expects for /api/notifications/reveal-secret. Centralised here so
|
// expects for /api/notifications/reveal-secret. Centralised here so
|
||||||
// the JSX call site stays a one-liner — `toggleSecret(key)`.
|
// the JSX call site stays a one-liner — `toggleSecret(key)`.
|
||||||
@@ -1223,8 +1221,10 @@ export function NotificationSettings() {
|
|||||||
setConfig(newConfig)
|
setConfig(newConfig)
|
||||||
setOriginalConfig(newConfig)
|
setOriginalConfig(newConfig)
|
||||||
|
|
||||||
// 2) Auto-configure PVE webhook
|
// 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications
|
||||||
try {
|
if (isPbs) {
|
||||||
|
setWebhookSetup({ status: "idle", fallback_commands: [], error: "" })
|
||||||
|
} else try {
|
||||||
const setup = await fetchApi<{
|
const setup = await fetchApi<{
|
||||||
configured: boolean
|
configured: boolean
|
||||||
secret?: string
|
secret?: string
|
||||||
@@ -1530,7 +1530,7 @@ export function NotificationSettings() {
|
|||||||
<Input
|
<Input
|
||||||
type={showSecrets["tg_token"] ? "text" : "password"}
|
type={showSecrets["tg_token"] ? "text" : "password"}
|
||||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||||
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
|
placeholder={t("settings.notifications.ui.botTokenPlaceholder")}
|
||||||
value={secretValue("tg_token", config.channels.telegram?.bot_token || "")}
|
value={secretValue("tg_token", config.channels.telegram?.bot_token || "")}
|
||||||
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
@@ -1542,6 +1542,7 @@ export function NotificationSettings() {
|
|||||||
{showSecrets["tg_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
{showSecrets["tg_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.botTokenHelp")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.chatId")}</Label>
|
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.chatId")}</Label>
|
||||||
@@ -2068,6 +2069,7 @@ export function NotificationSettings() {
|
|||||||
Enable toggle + Test button pattern as the other
|
Enable toggle + Test button pattern as the other
|
||||||
channels. */}
|
channels. */}
|
||||||
<TabsContent value="apprise" className="space-y-3 pt-2">
|
<TabsContent value="apprise" className="space-y-3 pt-2">
|
||||||
|
<AppriseServiceHelp />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
|
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
|
||||||
@@ -2303,6 +2305,12 @@ export function NotificationSettings() {
|
|||||||
|
|
||||||
// Restore previously saved model for the new provider (if any)
|
// Restore previously saved model for the new provider (if any)
|
||||||
const savedModel = config.ai_models?.[v] || ''
|
const savedModel = config.ai_models?.[v] || ''
|
||||||
|
setShowSecrets(p => ({ ...p, ai_key: false }))
|
||||||
|
setRevealedSecrets(p => {
|
||||||
|
const next = { ...p }
|
||||||
|
delete next.ai_key
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
updateConfig(p => ({
|
updateConfig(p => ({
|
||||||
...p,
|
...p,
|
||||||
@@ -2362,11 +2370,13 @@ export function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* API Key (not shown for Ollama) */}
|
{/* Ollama gateways may require a key; native servers do not. */}
|
||||||
{config.ai_provider !== "ollama" && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
|
<Label htmlFor="notification-ai-api-key" className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
|
||||||
|
{config.ai_provider === "ollama" && (
|
||||||
|
<span className="text-xs text-muted-foreground">({t("settings.notifications.ui.optional")})</span>
|
||||||
|
)}
|
||||||
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl && (
|
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl && (
|
||||||
<a
|
<a
|
||||||
href={AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl}
|
href={AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl}
|
||||||
@@ -2380,9 +2390,10 @@ export function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
id="notification-ai-api-key"
|
||||||
type={showSecrets["ai_key"] ? "text" : "password"}
|
type={showSecrets["ai_key"] ? "text" : "password"}
|
||||||
className="h-9 text-sm font-mono"
|
className="h-9 text-sm font-mono"
|
||||||
placeholder="sk-..."
|
placeholder={config.ai_provider === "ollama" ? "" : "sk-..."}
|
||||||
value={secretValue("ai_key", config.ai_api_keys?.[config.ai_provider] || "")}
|
value={secretValue("ai_key", config.ai_api_keys?.[config.ai_provider] || "")}
|
||||||
onChange={e => updateConfig(p => ({
|
onChange={e => updateConfig(p => ({
|
||||||
...p,
|
...p,
|
||||||
@@ -2400,8 +2411,12 @@ export function NotificationSettings() {
|
|||||||
{showSecrets["ai_key"] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showSecrets["ai_key"] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{config.ai_provider === "ollama" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.notifications.ai.ollamaApiKeyHint")}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Model - selector with Load button for all providers */}
|
{/* Model - selector with Load button for all providers */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -2761,6 +2776,75 @@ export function NotificationSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Notification history ── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-start gap-3 py-1 text-left"
|
||||||
|
onClick={() => setShowHistory(open => !open)}
|
||||||
|
aria-expanded={showHistory}
|
||||||
|
>
|
||||||
|
<History className="h-5 w-5 text-blue-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium">{t("settings.notifications.history.title")}</span>
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.description")}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={`h-4 w-4 mt-1 shrink-0 text-muted-foreground transition-transform ${showHistory ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{showHistory && (
|
||||||
|
historyError ? (
|
||||||
|
<p className="text-xs sm:text-sm text-red-400">{t("settings.notifications.history.loadFailed")}</p>
|
||||||
|
) : history === null ? (
|
||||||
|
<div className="flex justify-center py-3"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
|
||||||
|
{history.map(entry => {
|
||||||
|
const sent = Boolean(entry.success)
|
||||||
|
const severity = (entry.severity || "").toLowerCase()
|
||||||
|
const detail = !sent ? entry.error_message : entry.event_type === "digest" ? entry.message : null
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="flex items-start gap-2.5 p-2.5 rounded-md bg-muted/30 border border-border/50">
|
||||||
|
{sent ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.sent")} />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.notSent")} />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-xs sm:text-sm font-medium block truncate">{entry.title || entry.event_type}</span>
|
||||||
|
<span className="text-[11px] sm:text-xs text-muted-foreground">
|
||||||
|
{entry.channel ? entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1) : ""} · {new Date(entry.sent_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{detail && (
|
||||||
|
<p className={`text-[11px] sm:text-xs mt-0.5 break-words ${sent ? "text-muted-foreground" : "text-red-400"}`}>{detail}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(severity === "critical" || severity === "warning" || severity === "info") && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[10px] shrink-0 ${
|
||||||
|
severity === "critical"
|
||||||
|
? "border-red-500/30 text-red-400"
|
||||||
|
: severity === "warning"
|
||||||
|
? "border-amber-500/30 text-amber-400"
|
||||||
|
: "border-blue-500/30 text-blue-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{severity === "critical"
|
||||||
|
? t("settings.notifications.history.severityCritical")
|
||||||
|
: severity === "warning"
|
||||||
|
? t("settings.notifications.history.severityWarning")
|
||||||
|
: t("settings.notifications.history.severityInfo")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user