mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-10-20 17:19:57 +00:00
Compare commits
1933 Commits
2020.11.6
...
trigger-sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e415923553 | ||
|
|
95c638991d | ||
|
|
e2ada42001 | ||
|
|
22e50b4ace | ||
|
|
334484de7f | ||
|
|
180a7c3990 | ||
|
|
d5f33de808 | ||
|
|
6539f0df6f | ||
|
|
1504278223 | ||
|
|
9f3767b23d | ||
|
|
e0d7985369 | ||
|
|
2968a5717c | ||
|
|
e2b25fe7ce | ||
|
|
8601f5c49a | ||
|
|
42279461e0 | ||
|
|
409447d6ca | ||
|
|
5b313db49d | ||
|
|
d64618600d | ||
|
|
1ee01b1d5e | ||
|
|
af590202c3 | ||
|
|
12ca2fb624 | ||
|
|
ea95f83742 | ||
|
|
e4d4da601c | ||
|
|
0582f6fd39 | ||
|
|
f254af8326 | ||
|
|
3333770246 | ||
|
|
ee5ded29ac | ||
|
|
f530db98ff | ||
|
|
911f9d661f | ||
|
|
9935eac146 | ||
|
|
eae2c9e221 | ||
|
|
1a67fe8a83 | ||
|
|
3af565267b | ||
|
|
d09460a971 | ||
|
|
c65329442a | ||
|
|
48430dfa28 | ||
|
|
70e2de372d | ||
|
|
75784480ab | ||
|
|
8a70ba841d | ||
|
|
77733829d7 | ||
|
|
d4b67f1946 | ||
|
|
51ab138bb1 | ||
|
|
b81413c8b2 | ||
|
|
2ec33c6ef3 | ||
|
|
68b2c38c7c | ||
|
|
1ca22799d1 | ||
|
|
549dddcb11 | ||
|
|
131af90469 | ||
|
|
c7c39da7c6 | ||
|
|
8310c426f0 | ||
|
|
bb8f91e39a | ||
|
|
a359b9a3d5 | ||
|
|
e130ebad1f | ||
|
|
f5b996b66c | ||
|
|
05e0c7c3ab | ||
|
|
6c1203e4bf | ||
|
|
5fbcaa8edd | ||
|
|
00d217b5f7 | ||
|
|
c0e35376f3 | ||
|
|
2be84e1282 | ||
|
|
08f10c96ef | ||
|
|
12f8ccdf02 | ||
|
|
d63e78cf34 | ||
|
|
65d97ca924 | ||
|
|
5770cafea9 | ||
|
|
0177cd9528 | ||
|
|
91a8fae9b5 | ||
|
|
f16a4ce3ef | ||
|
|
306f63c75b | ||
|
|
2a0312318d | ||
|
|
695a23a454 | ||
|
|
7366673eea | ||
|
|
53fa0fe215 | ||
|
|
1ba621be60 | ||
|
|
5117364625 | ||
|
|
986b92aee4 | ||
|
|
12d26b05af | ||
|
|
e6c9704505 | ||
|
|
8ab396d77c | ||
|
|
8438448843 | ||
|
|
362edb9a61 | ||
|
|
1ff53e1853 | ||
|
|
cfd28dbb5c | ||
|
|
cbec558289 | ||
|
|
ca3a2937d0 | ||
|
|
3e67fc12c5 | ||
|
|
f6faa18409 | ||
|
|
21ae2c2e54 | ||
|
|
eb3986bea2 | ||
|
|
5d6738ced8 | ||
|
|
2f2fecddf2 | ||
|
|
218ba3601e | ||
|
|
4c3f60c44b | ||
|
|
cb85e5e464 | ||
|
|
5b46235872 | ||
|
|
70f675ac82 | ||
|
|
bf0c714ea4 | ||
|
|
c95df56e8d | ||
|
|
5f3d851954 | ||
|
|
10c69dcdae | ||
|
|
bdd81ce3a9 | ||
|
|
17ee234be4 | ||
|
|
61034dfa7b | ||
|
|
185cd362fb | ||
|
|
e2ca357774 | ||
|
|
3dea7fc4e8 | ||
|
|
01ba591bc9 | ||
|
|
640b7d46e3 | ||
|
|
d6560c51ee | ||
|
|
3e9b1938c6 | ||
|
|
44ce8de71f | ||
|
|
0bbd15bfda | ||
|
|
591b9a4d87 | ||
|
|
5ee7d16687 | ||
|
|
4ab4350c58 | ||
|
|
4ea7133fa8 | ||
|
|
627d67f9d0 | ||
|
|
eb37655598 | ||
|
|
19b62dd0d4 | ||
|
|
b2ad1ceea3 | ||
|
|
c1545b5b78 | ||
|
|
2c2f04ba85 | ||
|
|
77e7bf51b7 | ||
|
|
a42d71dcef | ||
|
|
1ff0432f4d | ||
|
|
54afd6e1c8 | ||
|
|
458c493a74 | ||
|
|
8ac8ecb17e | ||
|
|
eac167067e | ||
|
|
aa7f4aafeb | ||
|
|
d2183fa12b | ||
|
|
928f32bb4f | ||
|
|
cbe21303c4 | ||
|
|
94987c04b8 | ||
|
|
d4ba46a846 | ||
|
|
1a22d83895 | ||
|
|
6b73bf5c28 | ||
|
|
c9c9451c36 | ||
|
|
1882d448ea | ||
|
|
2f11c9c9e3 | ||
|
|
02bdc4b555 | ||
|
|
1a1ee50d9d | ||
|
|
50dc09d1a9 | ||
|
|
130efd340c | ||
|
|
00bc13c049 | ||
|
|
3caad67f61 | ||
|
|
13783f0d4a | ||
|
|
eae97ba3f4 | ||
|
|
134dad7357 | ||
|
|
1c4d2e8dec | ||
|
|
f2d7be3aac | ||
|
|
d06edb2dd6 | ||
|
|
7fa15b334a | ||
|
|
ffb4e2d6d7 | ||
|
|
bd8047ae9c | ||
|
|
49bc0624af | ||
|
|
5e1d764eb3 | ||
|
|
0064d93d75 | ||
|
|
5a838ecfe7 | ||
|
|
c37b5effd7 | ||
|
|
ca7f3e8acb | ||
|
|
b0cdb91d5e | ||
|
|
4829eb8ae1 | ||
|
|
1bb814b793 | ||
|
|
918fcb7d62 | ||
|
|
bbfd899564 | ||
|
|
12c4d9da87 | ||
|
|
6b4fd9b6b8 | ||
|
|
07c22f4a60 | ||
|
|
252e1e2ac0 | ||
|
|
b684c8673e | ||
|
|
547f42439d | ||
|
|
c51ceb000f | ||
|
|
4cbede1bc8 | ||
|
|
5eac8c7780 | ||
|
|
ab78d87304 | ||
|
|
09166e3867 | ||
|
|
8a5c813cdd | ||
|
|
4200622f43 | ||
|
|
c4452a85b4 | ||
|
|
e57de4a3c1 | ||
|
|
9fd2c91c55 | ||
|
|
fbd70013a8 | ||
|
|
8d18f3e66e | ||
|
|
5f5754e860 | ||
|
|
974c882b9a | ||
|
|
a9ea90096b | ||
|
|
45c72c426e | ||
|
|
4e5b75fe19 | ||
|
|
3cd617e68f | ||
|
|
ddff02f73b | ||
|
|
b59347b3d3 | ||
|
|
1dc769076f | ||
|
|
f150a19c0f | ||
|
|
c4bc1e3824 | ||
|
|
eca99b69db | ||
|
|
043af72847 | ||
|
|
05c7b6c639 | ||
|
|
3385c99f1f | ||
|
|
895117f857 | ||
|
|
9e3135e2de | ||
|
|
9a1c517437 | ||
|
|
c0c0c4b7ad | ||
|
|
be6e39fed0 | ||
|
|
b384921ee0 | ||
|
|
0d05a6eae3 | ||
|
|
430aef68c6 | ||
|
|
eac6070e12 | ||
|
|
6693b7c2e6 | ||
|
|
7898c3e433 | ||
|
|
420ecd064e | ||
|
|
4289be53f8 | ||
|
|
29b41b564e | ||
|
|
998eb69583 | ||
|
|
8ebc097ff4 | ||
|
|
c05984ca49 | ||
|
|
1a700c3013 | ||
|
|
a9c92cdec8 | ||
|
|
da8b938d5b | ||
|
|
71e91328f1 | ||
|
|
6356be4c52 | ||
|
|
e26e5440b6 | ||
|
|
fecfbd1a3e | ||
|
|
c00d6dfc76 | ||
|
|
85be66d90d | ||
|
|
1ac506b391 | ||
|
|
f7738b77de | ||
|
|
824037bb7d | ||
|
|
221292ad14 | ||
|
|
16f8c75e9f | ||
|
|
90a37079f1 | ||
|
|
798092af5e | ||
|
|
2a622a929d | ||
|
|
ca8eeaa68c | ||
|
|
d1b8ac1249 | ||
|
|
3f629c4d60 | ||
|
|
3fa910e68b | ||
|
|
e3cf2989c9 | ||
|
|
136b2f402d | ||
|
|
8d18d2d9c6 | ||
|
|
f18213361a | ||
|
|
18d9d32bca | ||
|
|
1246e429c9 | ||
|
|
77bc46bc37 | ||
|
|
ce16963c94 | ||
|
|
a70e8cfe58 | ||
|
|
ba922a1aaa | ||
|
|
b09230a884 | ||
|
|
f1cb9ca08e | ||
|
|
06513e88c6 | ||
|
|
b4a79bd068 | ||
|
|
dfd8fe84e0 | ||
|
|
4857c2e243 | ||
|
|
7d384f6160 | ||
|
|
672a7621f9 | ||
|
|
f0e2fb3f57 | ||
|
|
8c3a520512 | ||
|
|
22e50d56db | ||
|
|
a0735f3585 | ||
|
|
50a2e8fde3 | ||
|
|
55ed63cc79 | ||
|
|
97e9dfff3f | ||
|
|
501c9579fb | ||
|
|
f9aedadee6 | ||
|
|
c3c17b2bc3 | ||
|
|
a894c4589e | ||
|
|
56a8a1b5a1 | ||
|
|
be3f7a6c37 | ||
|
|
906e400ab7 | ||
|
|
a9265afd4c | ||
|
|
d26058ac80 | ||
|
|
ebd1f30606 | ||
|
|
c78e077649 | ||
|
|
07619223b0 | ||
|
|
25c326ec6c | ||
|
|
df167b94c2 | ||
|
|
3730908881 | ||
|
|
975dc1bc11 | ||
|
|
31409f0c32 | ||
|
|
b19273227b | ||
|
|
f89179fb03 | ||
|
|
90c971f9f1 | ||
|
|
d685780a4a | ||
|
|
b6bc8b7b7c | ||
|
|
92daba898f | ||
|
|
138843591e | ||
|
|
0814552b2a | ||
|
|
0e0fadd72d | ||
|
|
5426bd4392 | ||
|
|
3520a65099 | ||
|
|
b15a5c2c87 | ||
|
|
a8af04ff82 | ||
|
|
2148de45a0 | ||
|
|
c4143dacee | ||
|
|
a8025e77b3 | ||
|
|
dd1e76be93 | ||
|
|
36f997959a | ||
|
|
c1faed163a | ||
|
|
9ca927dbe7 | ||
|
|
02c6011818 | ||
|
|
2e96b16396 | ||
|
|
53b8de6c1c | ||
|
|
daea9f893c | ||
|
|
d1b5b1734c | ||
|
|
74a5899626 | ||
|
|
202ebf6d4e | ||
|
|
2c7b417e25 | ||
|
|
bb5e138134 | ||
|
|
3a2c3e2f84 | ||
|
|
d5be0c34ac | ||
|
|
ea5431ef2b | ||
|
|
9c4cdcd11f | ||
|
|
e5ef6333e4 | ||
|
|
98779a48b1 | ||
|
|
9d4848ee77 | ||
|
|
5126820619 | ||
|
|
8b5c808e8c | ||
|
|
9c75996c40 | ||
|
|
d524778e42 | ||
|
|
52d4bc660e | ||
|
|
8884696a6c | ||
|
|
d493ccde28 | ||
|
|
1ececaaaa2 | ||
|
|
91b48ad432 | ||
|
|
f3fe40a19f | ||
|
|
cf4b29c425 | ||
|
|
4344e14a9d | ||
|
|
df935ec423 | ||
|
|
e7f9f7504e | ||
|
|
5721b2353a | ||
|
|
c9de846d0e | ||
|
|
a598108c26 | ||
|
|
5467aa399d | ||
|
|
da052b074a | ||
|
|
90c035edd0 | ||
|
|
fc4eb44a24 | ||
|
|
a71111b378 | ||
|
|
52e0c7e484 | ||
|
|
e32970f191 | ||
|
|
897cc36017 | ||
|
|
d79c575860 | ||
|
|
1f19f84edd | ||
|
|
27c37b8b84 | ||
|
|
06a5dd3153 | ||
|
|
b5bf270d22 | ||
|
|
8e71d69a64 | ||
|
|
06edb6f8a8 | ||
|
|
dca82ec0a1 | ||
|
|
9c82ce4103 | ||
|
|
8a23a9eb1b | ||
|
|
e1b7e515df | ||
|
|
c8ff335ed7 | ||
|
|
5736da8ab7 | ||
|
|
060bba4dce | ||
|
|
4c573991d2 | ||
|
|
7fd6dce55f | ||
|
|
1861d756e9 | ||
|
|
c36c041f5e | ||
|
|
c3d877bdd2 | ||
|
|
1242030d4a | ||
|
|
1626e74608 | ||
|
|
b1b913777f | ||
|
|
190894010c | ||
|
|
765265723c | ||
|
|
7e20502379 | ||
|
|
366fc30e9d | ||
|
|
aa91788a69 | ||
|
|
375789b019 | ||
|
|
140b769a42 | ||
|
|
88d718271d | ||
|
|
6ed26cdd1f | ||
|
|
d1851fa607 | ||
|
|
e846157c52 | ||
|
|
e190bb4c1a | ||
|
|
137fbe7acd | ||
|
|
9ccdb2ae3a | ||
|
|
f5f7515744 | ||
|
|
ddadbec7e3 | ||
|
|
d24543e103 | ||
|
|
f80c4c9565 | ||
|
|
480b383782 | ||
|
|
d3efd4c24b | ||
|
|
67a0acffa2 | ||
|
|
41b07da399 | ||
|
|
a6ce55d5b5 | ||
|
|
98c01fe1b3 | ||
|
|
51df986222 | ||
|
|
9c625f93a5 | ||
|
|
7101d47e2e | ||
|
|
eb85be2770 | ||
|
|
2da27937a5 | ||
|
|
2a29b801a4 | ||
|
|
57e65714b0 | ||
|
|
0ae40cb51c | ||
|
|
ddd195dfc6 | ||
|
|
54b9f23ec5 | ||
|
|
242dd3e626 | ||
|
|
1b8acb5b60 | ||
|
|
a7ab96ab12 | ||
|
|
06ab11cf87 | ||
|
|
1410a1b06e | ||
|
|
5baf19f7a3 | ||
|
|
6c66a7ba17 | ||
|
|
37b6e09475 | ||
|
|
e08c8ca26d | ||
|
|
2c09e7929f | ||
|
|
3e760f0d85 | ||
|
|
3cc6bd19ad | ||
|
|
b7ddfba71d | ||
|
|
32f21d208f | ||
|
|
ed7edd9fe0 | ||
|
|
fd3c995c7c | ||
|
|
c0d1a2d53b | ||
|
|
76bc3015a7 | ||
|
|
ad2896243b | ||
|
|
d0dcded42d | ||
|
|
a0dfa01287 | ||
|
|
4ec5c90180 | ||
|
|
a0c813bfc1 | ||
|
|
5f7b3a7087 | ||
|
|
6426f02a2c | ||
|
|
7fef92c480 | ||
|
|
c64744dedf | ||
|
|
72a2088931 | ||
|
|
db54556b0f | ||
|
|
a2653d8462 | ||
|
|
ef778238f6 | ||
|
|
4cc0ddc35d | ||
|
|
a0429179a0 | ||
|
|
5cfb45c668 | ||
|
|
a53b7041f5 | ||
|
|
f534fae293 | ||
|
|
f7cbd968d2 | ||
|
|
844d76290c | ||
|
|
8c8122eee0 | ||
|
|
d63f0d5e0b | ||
|
|
96f4ba5d25 | ||
|
|
72e64676da | ||
|
|
883e54f989 | ||
|
|
c2d4be3304 | ||
|
|
de737ddb91 | ||
|
|
11ec6dd9ac | ||
|
|
df7541e397 | ||
|
|
95ac53d780 | ||
|
|
e8c4b32a65 | ||
|
|
eca535c978 | ||
|
|
9088810b49 | ||
|
|
172a7053ed | ||
|
|
3d5bd2adef | ||
|
|
cb03d039f4 | ||
|
|
bb31b1bc6e | ||
|
|
727532858e | ||
|
|
c0868d9dac | ||
|
|
ce26e1dac6 | ||
|
|
c74f87ca12 | ||
|
|
043111b91c | ||
|
|
5c579e557c | ||
|
|
f8f51740c1 | ||
|
|
176b63df52 | ||
|
|
e1979357a5 | ||
|
|
030527a4f2 | ||
|
|
cca74da1f3 | ||
|
|
928aff342f | ||
|
|
60a97235df | ||
|
|
c77779cf9d | ||
|
|
9351796ba8 | ||
|
|
bef0f023d4 | ||
|
|
3116f183f5 | ||
|
|
16b71a22d1 | ||
|
|
5f4581042c | ||
|
|
6976a4cf2e | ||
|
|
68d86b3b7b | ||
|
|
d7d34d36c8 | ||
|
|
68da328cc5 | ||
|
|
78870186d7 | ||
|
|
d634273b48 | ||
|
|
2d970eee02 | ||
|
|
1f0ea3c6f7 | ||
|
|
d736913f7f | ||
|
|
3e95a9d282 | ||
|
|
7cd7259992 | ||
|
|
87385cf28e | ||
|
|
3a00c94325 | ||
|
|
38d5d2307f | ||
|
|
a0c12e7228 | ||
|
|
b6625ad909 | ||
|
|
6f01341055 | ||
|
|
6762a4153a | ||
|
|
31200df89f | ||
|
|
18e422ca77 | ||
|
|
1b362716e3 | ||
|
|
1e49129197 | ||
|
|
a8f818fca5 | ||
|
|
0f600da096 | ||
|
|
b04efe4eac | ||
|
|
7361d39231 | ||
|
|
059c0df16c | ||
|
|
6f6b849335 | ||
|
|
a390500309 | ||
|
|
7c576da32c | ||
|
|
6d021c1659 | ||
|
|
37c1c89d44 | ||
|
|
010043f116 | ||
|
|
b1010c3c61 | ||
|
|
7f0204bfc3 | ||
|
|
a508cc5efd | ||
|
|
65c90696d5 | ||
|
|
b9f47898d6 | ||
|
|
26f554e46a | ||
|
|
b57889c84f | ||
|
|
77fd1b4017 | ||
|
|
ab6745bc99 | ||
|
|
a5ea3cae72 | ||
|
|
8bcd1b4efd | ||
|
|
a24657e565 | ||
|
|
b7721420fa | ||
|
|
6c564fe4fd | ||
|
|
012bfd7e6c | ||
|
|
a70f81aa01 | ||
|
|
1376a38de5 | ||
|
|
1827ecda65 | ||
|
|
994c981228 | ||
|
|
5bbfbf44ae | ||
|
|
ace58ba735 | ||
|
|
f9840306a0 | ||
|
|
322b3bbb4e | ||
|
|
501318f468 | ||
|
|
0234f38b23 | ||
|
|
8743e0072f | ||
|
|
a79e06afa7 | ||
|
|
682b8e0535 | ||
|
|
d70aa5f9a9 | ||
|
|
1c815dcad1 | ||
|
|
afa467a32b | ||
|
|
274218d48e | ||
|
|
7e73df26ab | ||
|
|
ef8fc80c95 | ||
|
|
05c39144e3 | ||
|
|
f5cd35af47 | ||
|
|
c69ecdafd0 | ||
|
|
fa90c247ec | ||
|
|
0cd7bd47bb | ||
|
|
36d48d19fc | ||
|
|
9322b68d47 | ||
|
|
e11ff64b15 | ||
|
|
3776dabfcf | ||
|
|
d4e5831f0f | ||
|
|
7b3b478e88 | ||
|
|
f5afe13e91 | ||
|
|
49ce468d83 | ||
|
|
b26551c812 | ||
|
|
394ba580d2 | ||
|
|
2f7a54f5fd | ||
|
|
360e085926 | ||
|
|
042921925d | ||
|
|
dcf024387b | ||
|
|
e1232bc9e7 | ||
|
|
d96598b5dd | ||
|
|
2605f85668 | ||
|
|
2c8e6ca0cd | ||
|
|
0225f574be | ||
|
|
34090bf2eb | ||
|
|
5ae585ce13 | ||
|
|
2bb10a32d7 | ||
|
|
435743dd2c | ||
|
|
98589fba6d | ||
|
|
32da679e02 | ||
|
|
44daffc65b | ||
|
|
0aafda1477 | ||
|
|
60604e33b9 | ||
|
|
98268b377a | ||
|
|
de54979471 | ||
|
|
ee6e339587 | ||
|
|
c16cf89318 | ||
|
|
c66cb7423e | ||
|
|
f5bd95a519 | ||
|
|
500f9ec1c1 | ||
|
|
a4713d4a1e | ||
|
|
04452dfb1a | ||
|
|
69d09851d9 | ||
|
|
1b649fe5cd | ||
|
|
38572a5a86 | ||
|
|
f5f51169e6 | ||
|
|
07c2178ae1 | ||
|
|
f30d21361f | ||
|
|
6adb4fbcf7 | ||
|
|
d73962bd7d | ||
|
|
f4b43739da | ||
|
|
4838b280ad | ||
|
|
f93b753c03 | ||
|
|
de06361cb0 | ||
|
|
15ce48c8aa | ||
|
|
38758d05a8 | ||
|
|
a79fa14ee7 | ||
|
|
1eb95b4d33 | ||
|
|
d04e47f5b3 | ||
|
|
dad5118f21 | ||
|
|
acc0e5c989 | ||
|
|
204fcdf479 | ||
|
|
93ba8a3574 | ||
|
|
f2f9e3b514 | ||
|
|
61288559b3 | ||
|
|
bd2c99a455 | ||
|
|
1937348b24 | ||
|
|
b7b2fae325 | ||
|
|
11115923b2 | ||
|
|
295133d2e9 | ||
|
|
3018b851c8 | ||
|
|
222c3fd485 | ||
|
|
9650fd2ba1 | ||
|
|
c88fd9a7d9 | ||
|
|
1611beccd1 | ||
|
|
71077fb0f7 | ||
|
|
9647fba98f | ||
|
|
86f004e45a | ||
|
|
a98334ede8 | ||
|
|
e19c2d6805 | ||
|
|
847736dab8 | ||
|
|
45f930ab21 | ||
|
|
6ea54f1ddb | ||
|
|
81ce0a60f6 | ||
|
|
bf5d839c22 | ||
|
|
fc385cfac0 | ||
|
|
12d55b8411 | ||
|
|
e60af93e2b | ||
|
|
1691f0eac7 | ||
|
|
be4a6a1564 | ||
|
|
24c5613a50 | ||
|
|
5266927bf7 | ||
|
|
4bd2000174 | ||
|
|
b8178414a4 | ||
|
|
f9bc2f5993 | ||
|
|
f1a72ee418 | ||
|
|
b19dcef5b7 | ||
|
|
1f92ab42ca | ||
|
|
1f940a04fd | ||
|
|
f771eaab5f | ||
|
|
d1379a8154 | ||
|
|
e488f02557 | ||
|
|
f11cc86254 | ||
|
|
175667bfe8 | ||
|
|
0a0f14ddea | ||
|
|
9e08677ade | ||
|
|
abbf8b9b65 | ||
|
|
96d5fc244e | ||
|
|
3b38047fd4 | ||
|
|
48e9e1c4f9 | ||
|
|
355961a1eb | ||
|
|
e68190b6b6 | ||
|
|
e7cc7e971f | ||
|
|
ee027eb510 | ||
|
|
a584300bf3 | ||
|
|
16e1f839d7 | ||
|
|
c2123f0903 | ||
|
|
9fbeb2a769 | ||
|
|
3e0723ec24 | ||
|
|
3e5f1d96b5 | ||
|
|
be87082502 | ||
|
|
f997e51249 | ||
|
|
456316fdd4 | ||
|
|
9a7d547394 | ||
|
|
d3031e2eae | ||
|
|
35bd66119a | ||
|
|
9be3b47e0e | ||
|
|
4bed8c1327 | ||
|
|
254ec2d1af | ||
|
|
e4ee3e4226 | ||
|
|
65545e7218 | ||
|
|
8b4e8e9804 | ||
|
|
5d1ef34f17 | ||
|
|
9504eff889 | ||
|
|
d5828a6815 | ||
|
|
488f246f75 | ||
|
|
000d4ec78a | ||
|
|
6c0415163b | ||
|
|
0205cbb78b | ||
|
|
72db559adc | ||
|
|
a57c145870 | ||
|
|
759fd1077a | ||
|
|
fb90e6d07e | ||
|
|
86d17acd83 | ||
|
|
6eb8de02eb | ||
|
|
f4df298cb3 | ||
|
|
9800955646 | ||
|
|
1706d14c9c | ||
|
|
cf68d9fd19 | ||
|
|
6f2f8e88a6 | ||
|
|
c896b60410 | ||
|
|
0200c72db1 | ||
|
|
fe5705b35b | ||
|
|
3c3846240d | ||
|
|
b86a6d292f | ||
|
|
1feda7d89f | ||
|
|
73d795e05e | ||
|
|
e449205863 | ||
|
|
841f68c175 | ||
|
|
0df19cee91 | ||
|
|
d3f490bcc3 | ||
|
|
0fda5f6c4b | ||
|
|
e984797f3c | ||
|
|
334bcf48fb | ||
|
|
73f3627ebd | ||
|
|
0adf2864b4 | ||
|
|
f542c8e790 | ||
|
|
a7c1693911 | ||
|
|
bb497c0c9f | ||
|
|
95eee712a3 | ||
|
|
6aeac271fa | ||
|
|
1204852893 | ||
|
|
f6c3bdb6a8 | ||
|
|
fbb2776277 | ||
|
|
5ced4e2f3b | ||
|
|
61a7e6a87d | ||
|
|
88d25fc14e | ||
|
|
b5233cd398 | ||
|
|
109b8b47a0 | ||
|
|
c5566f40ca | ||
|
|
9dd5d89458 | ||
|
|
c6f31ce73f | ||
|
|
da9787bb58 | ||
|
|
4254b80c0a | ||
|
|
b4fd5b28f6 | ||
|
|
6a95f97ec9 | ||
|
|
fc171b674e | ||
|
|
17f5ff1cb1 | ||
|
|
b017fed329 | ||
|
|
4c69c7206e | ||
|
|
caf094815f | ||
|
|
4043503940 | ||
|
|
4cd80c4228 | ||
|
|
7fd38da403 | ||
|
|
7688e1b9cb | ||
|
|
61202db8b2 | ||
|
|
34c394c3d1 | ||
|
|
ebe9c32092 | ||
|
|
2108b218d8 | ||
|
|
b85b5041b4 | ||
|
|
7c29c56b9a | ||
|
|
207ae8ae4f | ||
|
|
c13531e9e3 | ||
|
|
0373030cb2 | ||
|
|
9635c70f2b | ||
|
|
ff54c5268c | ||
|
|
c7141caa12 | ||
|
|
d0bf2aa817 | ||
|
|
ed2f57f3ca | ||
|
|
744cd4ea39 | ||
|
|
b3ca08f2c2 | ||
|
|
afbafe44f9 | ||
|
|
a54e0a8401 | ||
|
|
df336dd493 | ||
|
|
778134f096 | ||
|
|
dc4a753fe3 | ||
|
|
f5b6feec77 | ||
|
|
08c40dfe98 | ||
|
|
98110a26d4 | ||
|
|
610b0e9adc | ||
|
|
be39275cd0 | ||
|
|
0c7fc10147 | ||
|
|
6dd9b573fd | ||
|
|
2c2f1afc48 | ||
|
|
8cf71ffa81 | ||
|
|
1123101c87 | ||
|
|
5adddc97e3 | ||
|
|
d09f35f079 | ||
|
|
9a3459434f | ||
|
|
fce0d2aaed | ||
|
|
842e550dda | ||
|
|
c9ee76f1d3 | ||
|
|
852771fbcf | ||
|
|
de1f3555b1 | ||
|
|
c0b75edfb7 | ||
|
|
a3204f4ebd | ||
|
|
84e4d70a37 | ||
|
|
cede47e95c | ||
|
|
75b3ebec7c | ||
|
|
b707a468d2 | ||
|
|
4e41255a57 | ||
|
|
3ceec044a8 | ||
|
|
3646ae070e | ||
|
|
a6caccd845 | ||
|
|
c6ddc8e427 | ||
|
|
8bfd07d66b | ||
|
|
d764f00580 | ||
|
|
d9b86fa2ab | ||
|
|
0ddce4d9bc | ||
|
|
8386b5cb3a | ||
|
|
8fc036874a | ||
|
|
2a625defc0 | ||
|
|
3f1e72d69f | ||
|
|
42374a3a3f | ||
|
|
2adebd9da6 | ||
|
|
3b2c75fbd7 | ||
|
|
19f6e12936 | ||
|
|
abe59ab1e5 | ||
|
|
79d8db6015 | ||
|
|
1b317f5e92 | ||
|
|
c262a39c11 | ||
|
|
6ee86ee062 | ||
|
|
b3a869429f | ||
|
|
e4e9dee02c | ||
|
|
2887934dbe | ||
|
|
daeec266cc | ||
|
|
3887fcfc93 | ||
|
|
ab83c51910 | ||
|
|
2ae2d0e107 | ||
|
|
613ef9010a | ||
|
|
675bea7835 | ||
|
|
3d74e07c5e | ||
|
|
692d34a13c | ||
|
|
440379680e | ||
|
|
165af46f54 | ||
|
|
4c2d729646 | ||
|
|
8ffd227849 | ||
|
|
64c5ba1635 | ||
|
|
37a247160e | ||
|
|
919f1e9149 | ||
|
|
d73d8d00f0 | ||
|
|
09c699a2fe | ||
|
|
cb992762d1 | ||
|
|
8f0cec10d5 | ||
|
|
4a0e17f050 | ||
|
|
b4c74404e3 | ||
|
|
649091f3bd | ||
|
|
a27be5d621 | ||
|
|
939eb81581 | ||
|
|
ee1daa0b35 | ||
|
|
242c05a19b | ||
|
|
9024085712 | ||
|
|
e0abb98aaf | ||
|
|
4ffa628a6e | ||
|
|
417ee418f2 | ||
|
|
0f79ba5a3d | ||
|
|
47fd849319 | ||
|
|
99e0eab958 | ||
|
|
0a753c55ca | ||
|
|
72d81e43dd | ||
|
|
83e5359bd2 | ||
|
|
51875bdcd5 | ||
|
|
ecabf9dea7 | ||
|
|
c1954f4426 | ||
|
|
0991f52100 | ||
|
|
fed4a05003 | ||
|
|
089635f4d3 | ||
|
|
15fa8de05c | ||
|
|
8fc91f5288 | ||
|
|
4461192fa7 | ||
|
|
2fe7c0dce6 | ||
|
|
e2e11faf18 | ||
|
|
fcbef6b78b | ||
|
|
10810fb1b9 | ||
|
|
92408bb893 | ||
|
|
61fc01915f | ||
|
|
fea60c57a2 | ||
|
|
c1ac6c0432 | ||
|
|
64ca530e66 | ||
|
|
08f290ca10 | ||
|
|
03849258eb | ||
|
|
32d0d84c53 | ||
|
|
83265c4dc5 | ||
|
|
a9cbeb21c9 | ||
|
|
1af4a362c2 | ||
|
|
b9e2cfad4d | ||
|
|
726ded70d3 | ||
|
|
ac56f1511f | ||
|
|
3d7d52a62b | ||
|
|
941e1f5c91 | ||
|
|
1a2b13018a | ||
|
|
da721f455e | ||
|
|
4e91db10a9 | ||
|
|
ba9bcd9e57 | ||
|
|
c193c91fe7 | ||
|
|
bdde24ae9e | ||
|
|
b56995be27 | ||
|
|
1f7199cf00 | ||
|
|
e48e024bb3 | ||
|
|
02c181c1ff | ||
|
|
70cf6cc0d9 | ||
|
|
9abf38f285 | ||
|
|
54dfba1faa | ||
|
|
ed778f09ee | ||
|
|
b044095e57 | ||
|
|
c41f13bf18 | ||
|
|
2ddb5ca53f | ||
|
|
fad75810ab | ||
|
|
4d9e30adef | ||
|
|
80a6171692 | ||
|
|
815669e6e3 | ||
|
|
a8133f0640 | ||
|
|
2809f23391 | ||
|
|
348fb56cb5 | ||
|
|
4afbedfa3d | ||
|
|
8d495aa437 | ||
|
|
9559ac06b9 | ||
|
|
e80d882395 | ||
|
|
14fcda5d78 | ||
|
|
14cd261b76 | ||
|
|
783395a27d | ||
|
|
a2dffe595e | ||
|
|
a0b28ebb97 | ||
|
|
89de909020 | ||
|
|
672b220f69 | ||
|
|
d59625e5b8 | ||
|
|
2947e8e8e9 | ||
|
|
5f04e4fb6a | ||
|
|
4c5d54b7a3 | ||
|
|
30932a83f8 | ||
|
|
1df0a5db2a | ||
|
|
9affa5316c | ||
|
|
a13c8d86b9 | ||
|
|
80248dc36d | ||
|
|
2ad122ec18 | ||
|
|
d7ec3646f9 | ||
|
|
030e1a92f3 | ||
|
|
3cf999b306 | ||
|
|
2d2926f7ff | ||
|
|
23ba0ad6a5 | ||
|
|
38fffb7641 | ||
|
|
03eda30e20 | ||
|
|
10c87d5a39 | ||
|
|
7a0c4c5060 | ||
|
|
5d2b5bada7 | ||
|
|
bde5c938a7 | ||
|
|
34afcef4f1 | ||
|
|
2ebb405871 | ||
|
|
1f7c067c90 | ||
|
|
9da4ea20a9 | ||
|
|
767c2bd91a | ||
|
|
7c1f03932e | ||
|
|
f3d1904e28 | ||
|
|
9cc87cabcd | ||
|
|
18299cf274 | ||
|
|
261c2431c6 | ||
|
|
d36fc938b8 | ||
|
|
dc0430f677 | ||
|
|
1e2dc93158 | ||
|
|
69a33777a7 | ||
|
|
57f0c9af1b | ||
|
|
14d26ad9aa | ||
|
|
b36316416b | ||
|
|
c634cc1f34 | ||
|
|
646725bb08 | ||
|
|
618c89c4d8 | ||
|
|
0dc442d0cb | ||
|
|
6ae664b448 | ||
|
|
18b43ce767 | ||
|
|
f9b474866b | ||
|
|
1a76035682 | ||
|
|
e332f4b2bd | ||
|
|
ab27fd7b57 | ||
|
|
12c0faf803 | ||
|
|
c0a409b25f | ||
|
|
2be33a80a7 | ||
|
|
d684aab207 | ||
|
|
ec6da7851e | ||
|
|
eb621f6a2c | ||
|
|
a1a9c55542 | ||
|
|
d15a7c27ca | ||
|
|
fb46335d16 | ||
|
|
48e666e1fc | ||
|
|
ff462ae976 | ||
|
|
23731d9a6e | ||
|
|
30df8ce5c7 | ||
|
|
951efd6b29 | ||
|
|
262fd05c6d | ||
|
|
2a6fc512e7 | ||
|
|
bb0d89f8fd | ||
|
|
e9ccc7ee19 | ||
|
|
a5103cc329 | ||
|
|
c24b811180 | ||
|
|
611963f5dd | ||
|
|
0958cd0c06 | ||
|
|
c406814794 | ||
|
|
c3459fd32a | ||
|
|
2072370ccc | ||
|
|
615758a1df | ||
|
|
cd10b597dd | ||
|
|
50c277137d | ||
|
|
99bc201688 | ||
|
|
0b09eb3659 | ||
|
|
a6795536ad | ||
|
|
a46536e9be | ||
|
|
c01bed9d97 | ||
|
|
2f4e06aadf | ||
|
|
b8249548ae | ||
|
|
5f98ab7e3e | ||
|
|
d195f19fa8 | ||
|
|
c67d4d7c0b | ||
|
|
5aa8028ff5 | ||
|
|
b71c6c60da | ||
|
|
4f272ad4fd | ||
|
|
611128c014 | ||
|
|
cbf73ceaa3 | ||
|
|
01e24a3e74 | ||
|
|
10dcf5c12f | ||
|
|
ebae1e70ee | ||
|
|
b1ddb917c8 | ||
|
|
d6c25c4188 | ||
|
|
170e85396e | ||
|
|
bf48d48c51 | ||
|
|
fc646db95f | ||
|
|
0769af9383 | ||
|
|
1f28e6ad93 | ||
|
|
2dab39bf90 | ||
|
|
dcd0592d44 | ||
|
|
7c4b20380e | ||
|
|
1d304bd6ff | ||
|
|
4ea27f6311 | ||
|
|
3dc36c3402 | ||
|
|
bae7fe4184 | ||
|
|
df030e6209 | ||
|
|
09d60b4957 | ||
|
|
004065ae33 | ||
|
|
854d337dd3 | ||
|
|
2c5bb3f714 | ||
|
|
7b63544474 | ||
|
|
97af1fc66e | ||
|
|
32d65722e9 | ||
|
|
d5f9fcfdc7 | ||
|
|
ffa524d3a4 | ||
|
|
9c7de4a6c3 | ||
|
|
b4e1e3e853 | ||
|
|
c7f7fbd41a | ||
|
|
cbddca2658 | ||
|
|
f4811a0243 | ||
|
|
024b813865 | ||
|
|
5919bc2252 | ||
|
|
8bca34ec6b | ||
|
|
8b5e96a8ad | ||
|
|
2d908ffcec | ||
|
|
c3f7a45d61 | ||
|
|
97b05c2078 | ||
|
|
aa9a774939 | ||
|
|
3388a13693 | ||
|
|
9957e3dd4c | ||
|
|
01c2bd1b0c | ||
|
|
2cd7f9d1b0 | ||
|
|
5fc9484f73 | ||
|
|
e6dfe83d62 | ||
|
|
3f88236495 | ||
|
|
96065ed704 | ||
|
|
7754424cb8 | ||
|
|
be842d5e6c | ||
|
|
c8f184f24c | ||
|
|
e82cb5da45 | ||
|
|
a968f6e90a | ||
|
|
3eac3a6178 | ||
|
|
b831dce443 | ||
|
|
e62324e43f | ||
|
|
a92058e6fc | ||
|
|
29b2de6998 | ||
|
|
057a048504 | ||
|
|
29a1e6f68b | ||
|
|
702cb4f5be | ||
|
|
13c10dbb47 | ||
|
|
2279c813d0 | ||
|
|
1b52b2d23b | ||
|
|
27ac96f5f9 | ||
|
|
f87209f66f | ||
|
|
b670efa47f | ||
|
|
c749e21d3f | ||
|
|
4f8f28b9f6 | ||
|
|
2b4f46f6b3 | ||
|
|
5d6e2eeaac | ||
|
|
a45789c906 | ||
|
|
d097044fa8 | ||
|
|
73778780ef | ||
|
|
df05c844c0 | ||
|
|
ebeff31bf6 | ||
|
|
037e42e894 | ||
|
|
13db0e5c70 | ||
|
|
dab75b597c | ||
|
|
a1bab8ad08 | ||
|
|
48c5dd064c | ||
|
|
fd998155c2 | ||
|
|
4a3ab4ba8d | ||
|
|
c76e7a22df | ||
|
|
d19166bb86 | ||
|
|
14bc771ba9 | ||
|
|
8f84eaa096 | ||
|
|
2fd51c36b8 | ||
|
|
c473d7ca62 | ||
|
|
2de5b2f0fb | ||
|
|
cf30810677 | ||
|
|
a8dc842f97 | ||
|
|
38509aa3b8 | ||
|
|
9be2b3bced | ||
|
|
ceed1bc318 | ||
|
|
389aab8d4a | ||
|
|
8b7aa7640c | ||
|
|
a5cc3cba63 | ||
|
|
9266062709 | ||
|
|
bacedd1622 | ||
|
|
7227f022b1 | ||
|
|
0ce91f2e25 | ||
|
|
fdb195cf59 | ||
|
|
b85936774a | ||
|
|
bd106be026 | ||
|
|
e588541fe3 | ||
|
|
d685d8539b | ||
|
|
bb3b8891bc | ||
|
|
44e4e727cc | ||
|
|
acc49579f6 | ||
|
|
48eb1e8958 | ||
|
|
a5e3f6f0b4 | ||
|
|
d309524fe7 | ||
|
|
bfb0a961cd | ||
|
|
b1a23f3980 | ||
|
|
1f69cf0fe6 | ||
|
|
b001aa882a | ||
|
|
e92d8695c7 | ||
|
|
acfa686bb6 | ||
|
|
3b3cd61e3d | ||
|
|
b82dbc0cac | ||
|
|
8d1a5c5d6a | ||
|
|
7a74d77d43 | ||
|
|
977fd8abe2 | ||
|
|
e048c71dc8 | ||
|
|
b8259471b0 | ||
|
|
5f9b999a3c | ||
|
|
ccd2c31390 | ||
|
|
deeaf2133b | ||
|
|
d004093a1e | ||
|
|
9275c6af34 | ||
|
|
890313701c | ||
|
|
4e4fa488f9 | ||
|
|
138fd7eec9 | ||
|
|
6e017a36c4 | ||
|
|
5bc7255756 | ||
|
|
8c7c2fca28 | ||
|
|
2fe358fb1e | ||
|
|
2c09021427 | ||
|
|
5297edb57d | ||
|
|
1b8ad44833 | ||
|
|
1b53ca92c5 | ||
|
|
cbe0adf53f | ||
|
|
eabd976d33 | ||
|
|
99023b9522 | ||
|
|
129a79ae24 | ||
|
|
f8ac2b202c | ||
|
|
0548afdb61 | ||
|
|
567806cd14 | ||
|
|
aa8910280d | ||
|
|
1d5806d0c7 | ||
|
|
942b5e6150 | ||
|
|
ae00ea178d | ||
|
|
7971be51b7 | ||
|
|
4ad69dc038 | ||
|
|
475b8c9cac | ||
|
|
f684c8f0dd | ||
|
|
e390a3e5d5 | ||
|
|
ca1f764080 | ||
|
|
1c75b515e0 | ||
|
|
5e266e58ac | ||
|
|
31401674d0 | ||
|
|
04ff9f431a | ||
|
|
7b46c4759d | ||
|
|
e73809d350 | ||
|
|
d79dcf74ca | ||
|
|
ff08ca5920 | ||
|
|
3299772f3c | ||
|
|
8bb4596d04 | ||
|
|
0440437369 | ||
|
|
46d0cc9777 | ||
|
|
f3e2ccce43 | ||
|
|
32d3a5224e | ||
|
|
32d1296da1 | ||
|
|
88795c56f0 | ||
|
|
6a075a49e3 | ||
|
|
6395be5b68 | ||
|
|
8c528f7ec5 | ||
|
|
a553ba5d24 | ||
|
|
61d79b6b9c | ||
|
|
7feab2e31a | ||
|
|
5dd0a7611b | ||
|
|
8eba766f77 | ||
|
|
12da8a0c55 | ||
|
|
6666637a77 | ||
|
|
9847e456cd | ||
|
|
b701e1917e | ||
|
|
393a11c696 | ||
|
|
19de0a22be | ||
|
|
b67ee216ae | ||
|
|
939c3f1b4a | ||
|
|
ad85fa29b6 | ||
|
|
f57aeab9ae | ||
|
|
383ea277b7 | ||
|
|
a32d1668ee | ||
|
|
e445a8aabf | ||
|
|
0de190268f | ||
|
|
9e5101aa39 | ||
|
|
e2ac5042d8 | ||
|
|
bfe1cb073c | ||
|
|
3a1364dfcd | ||
|
|
3f63414bb3 | ||
|
|
8b3a09e5b8 | ||
|
|
ca7dc8113b | ||
|
|
6d2a603cf9 | ||
|
|
d536ac8604 | ||
|
|
c67317571c | ||
|
|
d93def7f22 | ||
|
|
20e45e3c00 | ||
|
|
5758d42c91 | ||
|
|
d2dc78ae6a | ||
|
|
3fd3c02010 | ||
|
|
a82b4aa6c8 | ||
|
|
45e54d93c7 | ||
|
|
435241bccf | ||
|
|
1b8558ced3 | ||
|
|
4339cae241 | ||
|
|
4f2469fd98 | ||
|
|
a90e8be6bc | ||
|
|
dcaf36a8e5 | ||
|
|
908df3b234 | ||
|
|
1b445feaaa | ||
|
|
c05504a069 | ||
|
|
e37cee9818 | ||
|
|
dd3a4a1f47 | ||
|
|
b451e555d3 | ||
|
|
5fb2b99917 | ||
|
|
8984d4afd6 | ||
|
|
7ae8dfe587 | ||
|
|
c931a4c3e5 | ||
|
|
c58fa816d9 | ||
|
|
557f029aa0 | ||
|
|
e8e3cc2f67 | ||
|
|
b0e4983488 | ||
|
|
205f3a74dd | ||
|
|
21a5479a2e | ||
|
|
a9ab64a29a | ||
|
|
3edfaa1ee7 | ||
|
|
71903d906b | ||
|
|
36f4e494a2 | ||
|
|
9104b287e5 | ||
|
|
842c4b3864 | ||
|
|
244005471b | ||
|
|
0ca837903f | ||
|
|
8683d46ab6 | ||
|
|
c17006cc37 | ||
|
|
136d8613a5 | ||
|
|
670d05df95 | ||
|
|
93fc4e97a0 | ||
|
|
b86df0696e | ||
|
|
b0af73b0b5 | ||
|
|
f1e884b264 | ||
|
|
a2f43d8c7b | ||
|
|
b4cfbe46c1 | ||
|
|
2a006ae76d | ||
|
|
40812450df | ||
|
|
d2e0b0417c | ||
|
|
d4fd8f3f0d | ||
|
|
199b57c833 | ||
|
|
597a27ba33 | ||
|
|
d6e44b43b4 | ||
|
|
84c2053b57 | ||
|
|
2df3678fef | ||
|
|
920f9846ac | ||
|
|
3478005e70 | ||
|
|
e5d64f6c75 | ||
|
|
787695a763 | ||
|
|
a495fd6b3a | ||
|
|
80e67b3c57 | ||
|
|
a52272a7fe | ||
|
|
fb24ed3f1a | ||
|
|
8d8704e049 | ||
|
|
a60f25100d | ||
|
|
5be4a1f4dc | ||
|
|
caacb421c1 | ||
|
|
724eaddf19 | ||
|
|
c3019bce7e | ||
|
|
4ae61814d4 | ||
|
|
b651d63758 | ||
|
|
400d3981a2 | ||
|
|
69c2517d52 | ||
|
|
c8b49aba42 | ||
|
|
8071b107e7 | ||
|
|
603d19b075 | ||
|
|
a5ce2ef7cb | ||
|
|
f392dc5492 | ||
|
|
0c63883269 | ||
|
|
612d4f950b | ||
|
|
1799c765b4 | ||
|
|
809ac1ffca | ||
|
|
fefc99e825 | ||
|
|
d994170a9d | ||
|
|
d8c934365a | ||
|
|
e0fd31c390 | ||
|
|
22238c9c0e | ||
|
|
5ff96cfa5e | ||
|
|
e22a19df1a | ||
|
|
f57bc0db25 | ||
|
|
6ba6b5ea56 | ||
|
|
5dc9f9235e | ||
|
|
323fa2e637 | ||
|
|
0986419b2f | ||
|
|
f0bc952269 | ||
|
|
9266997482 | ||
|
|
75d252e21a | ||
|
|
368e94f95f | ||
|
|
3fbecf89db | ||
|
|
54e6ae5fd9 | ||
|
|
5b96074055 | ||
|
|
5503f93a75 | ||
|
|
eadc629cd9 | ||
|
|
cde45e2e7a | ||
|
|
050851a9ac | ||
|
|
86bd16b2ba | ||
|
|
ce9181b05f | ||
|
|
f7ba364076 | ||
|
|
3511c19726 | ||
|
|
d9ed58696b | ||
|
|
373f452774 | ||
|
|
e54efa681f | ||
|
|
79cd8ac390 | ||
|
|
dc24f332f8 | ||
|
|
99cdf7b028 | ||
|
|
54edfa53bc | ||
|
|
571c9a05c6 | ||
|
|
864b7bf023 | ||
|
|
e303431d74 | ||
|
|
19dd40275c | ||
|
|
4cf970e37a | ||
|
|
7947c27089 | ||
|
|
d0e2c8b694 | ||
|
|
19e3a859b0 | ||
|
|
e6557ded34 | ||
|
|
f4aae4522d | ||
|
|
2066aefd6d | ||
|
|
2f56cab953 | ||
|
|
883399f583 | ||
|
|
47f53501e5 | ||
|
|
b23a89e6fb | ||
|
|
7764decc37 | ||
|
|
88490140af | ||
|
|
61d56dce9c | ||
|
|
838af87ad7 | ||
|
|
8f263ab345 | ||
|
|
6b76086652 | ||
|
|
efa5205800 | ||
|
|
a0c8b77737 | ||
|
|
9ee0efe6c0 | ||
|
|
a2af63d050 | ||
|
|
da246dc40a | ||
|
|
3c52f87cdc | ||
|
|
d80d76a24d | ||
|
|
8653f7a0e1 | ||
|
|
8458d9e0f6 | ||
|
|
5d4ce94155 | ||
|
|
828cf773cc | ||
|
|
a902b55df7 | ||
|
|
f38cde4c68 | ||
|
|
4c9cbb112e | ||
|
|
3d814f3c44 | ||
|
|
f269f72082 | ||
|
|
f07193dc3c | ||
|
|
d2b706df05 | ||
|
|
e5817e9445 | ||
|
|
85313f26ea | ||
|
|
f864613ffb | ||
|
|
36ea8b2bb4 | ||
|
|
df9d62f874 | ||
|
|
4a6aaa8559 | ||
|
|
435f479984 | ||
|
|
e2f39059c6 | ||
|
|
531073d5ec | ||
|
|
ef5b6a5f4c | ||
|
|
03f0a136ab | ||
|
|
7a6663ba80 | ||
|
|
9dd5eee1ae | ||
|
|
bb474a5c14 | ||
|
|
6ab4dda5e8 | ||
|
|
8a553dbb59 | ||
|
|
1ee6c0491c | ||
|
|
cc50a91a42 | ||
|
|
637377f81d | ||
|
|
a90f70e017 | ||
|
|
949ecb255d | ||
|
|
15f62837c8 | ||
|
|
e5246a5b1d | ||
|
|
394d66290d | ||
|
|
79d541185f | ||
|
|
b433d129ef | ||
|
|
4b0278fee8 | ||
|
|
8c59e6d05a | ||
|
|
5c66278a1c | ||
|
|
7abe9487a0 | ||
|
|
73832dd6d6 | ||
|
|
6cc3df54e9 | ||
|
|
c07c7c5146 | ||
|
|
a6d1078fe3 | ||
|
|
eba6da485d | ||
|
|
de880e24ed | ||
|
|
f344df9e5c | ||
|
|
5af62a8834 | ||
|
|
800fb683f8 | ||
|
|
ad2566d58a | ||
|
|
6c679b07e1 | ||
|
|
aa4f4c8d47 | ||
|
|
b83da5d89f | ||
|
|
0afff9a9e2 | ||
|
|
0433d72ae6 | ||
|
|
d33beb06cd | ||
|
|
279d6ccd79 | ||
|
|
af628293f3 | ||
|
|
df6b815175 | ||
|
|
d6127832a7 | ||
|
|
8240623806 | ||
|
|
2b4527fa64 | ||
|
|
23143aede4 | ||
|
|
8b93f0aee7 | ||
|
|
5cc4a9a929 | ||
|
|
288d2e5bdb | ||
|
|
73d84113ea | ||
|
|
4b15945ca1 | ||
|
|
10720b2988 | ||
|
|
bb991b69bb | ||
|
|
7c9f6067c0 | ||
|
|
e960a70217 | ||
|
|
9b0a2e6da9 | ||
|
|
cd0c151bd9 | ||
|
|
b03c8c24dd | ||
|
|
4416b6524e | ||
|
|
c9d3f65cc8 | ||
|
|
0407122fbe | ||
|
|
5e871d9399 | ||
|
|
6df7a88666 | ||
|
|
5933b66b1c | ||
|
|
a85e816cd7 | ||
|
|
96f6c07912 | ||
|
|
40bcee38f3 | ||
|
|
6d2a38c96e | ||
|
|
dafc2cfec2 | ||
|
|
04f36e92e1 | ||
|
|
4f97013df4 | ||
|
|
53eae96a98 | ||
|
|
74530baeb7 | ||
|
|
271e4f0cc4 | ||
|
|
f4c7f2cae1 | ||
|
|
24cdb4787a | ||
|
|
57b1c21af4 | ||
|
|
f0eddb6926 | ||
|
|
7c74c1bd8c | ||
|
|
2d4a85ae43 | ||
|
|
d48c439737 | ||
|
|
874c50d3e8 | ||
|
|
4beaf571c2 | ||
|
|
58a948447e | ||
|
|
32af7ef28b | ||
|
|
208fb549b7 | ||
|
|
ab704c11cf | ||
|
|
966b962ccf | ||
|
|
4ea3695982 | ||
|
|
b2abe37d72 | ||
|
|
9bf8d15b01 | ||
|
|
70acbffc23 | ||
|
|
c9b1eb751e | ||
|
|
ad8d850ed7 | ||
|
|
1b0eb9397d | ||
|
|
8572f8c4e5 | ||
|
|
66565dde87 | ||
|
|
d54c23952f | ||
|
|
62b364ea29 | ||
|
|
e6f00144f2 | ||
|
|
8894984c12 | ||
|
|
49fbdedf6b | ||
|
|
0899c16895 | ||
|
|
0747a7e4b2 | ||
|
|
0123d7935d | ||
|
|
7a1009446b | ||
|
|
034606cd0f | ||
|
|
ddc30cfd7d | ||
|
|
f10fccaff8 | ||
|
|
31001280c8 | ||
|
|
9638775944 | ||
|
|
fbec0befde | ||
|
|
81e7fac848 | ||
|
|
97599b3e70 | ||
|
|
c94b23a3fd | ||
|
|
9758980ae0 | ||
|
|
6ab3fbaab3 | ||
|
|
4933ff83df | ||
|
|
71e12ecb2b | ||
|
|
36687530e0 | ||
|
|
e7b5864c03 | ||
|
|
9497f85db9 | ||
|
|
419f603571 | ||
|
|
f4f1fc524d | ||
|
|
6d9f44a900 | ||
|
|
aeb9b26d44 | ||
|
|
631f78f468 | ||
|
|
13cedb308e | ||
|
|
82c183e1a8 | ||
|
|
25cf1e7394 | ||
|
|
91509a4205 | ||
|
|
d93ebd15a2 | ||
|
|
85e7f817e6 | ||
|
|
772cadb435 | ||
|
|
853aeef583 | ||
|
|
cd07bde307 | ||
|
|
3057df3181 | ||
|
|
fe785622ec | ||
|
|
2b6829a786 | ||
|
|
7c6c982414 | ||
|
|
07eeb2eaf2 | ||
|
|
223f5b7bb1 | ||
|
|
8a9657c452 | ||
|
|
564e9811d0 | ||
|
|
b944b52b21 | ||
|
|
24aecdddf3 | ||
|
|
7bbfb60039 | ||
|
|
ece40008c7 | ||
|
|
0177b38ded | ||
|
|
16f2f63081 | ||
|
|
5f376c2a27 | ||
|
|
90a6f109ee | ||
|
|
e6fd0ef5dc | ||
|
|
d46ab56901 | ||
|
|
3b1ad5c0cd | ||
|
|
de8a241e72 | ||
|
|
a4a0b43d91 | ||
|
|
adf355e54f | ||
|
|
4f9e646b4c | ||
|
|
ce57d384ca | ||
|
|
d53d526673 | ||
|
|
cd8fc16bcb | ||
|
|
6b58970354 | ||
|
|
b70ed9a60d | ||
|
|
22c8ff1314 | ||
|
|
ba2cf8078e | ||
|
|
ef138b619b | ||
|
|
9252af5ddb | ||
|
|
bcef34012d | ||
|
|
2f18c177ae | ||
|
|
12487fb69d | ||
|
|
e629bab8ee | ||
|
|
e85d7c3d2e | ||
|
|
64c59d0fe9 | ||
|
|
522cbe3295 | ||
|
|
fd185fc326 | ||
|
|
6ddc135266 | ||
|
|
f8d5279d9c | ||
|
|
2acae9af57 | ||
|
|
b425d21d05 | ||
|
|
4c7ba20a58 | ||
|
|
a4f325dd2e | ||
|
|
a99bfa2926 | ||
|
|
bb127a614b | ||
|
|
6f2f005897 | ||
|
|
e22a20c165 | ||
|
|
20c2121e5f | ||
|
|
8a5831d6b2 | ||
|
|
fb81946240 | ||
|
|
4bec86c58c | ||
|
|
7034b79991 | ||
|
|
7b9a09dc4b | ||
|
|
0746c4dec5 | ||
|
|
6dadb933bd | ||
|
|
07197e6a50 | ||
|
|
6c79fb8325 | ||
|
|
7488750ee4 | ||
|
|
c9574254aa | ||
|
|
f466721ffa | ||
|
|
3834cead07 | ||
|
|
75975de201 | ||
|
|
cb9f998ef1 | ||
|
|
eb9ce8ea1f | ||
|
|
a5ed68b641 | ||
|
|
1ef46424ea | ||
|
|
53c99547d0 | ||
|
|
a34e7622d2 | ||
|
|
b234c18664 | ||
|
|
d8d594c728 | ||
|
|
1cd35841e8 | ||
|
|
d05b7edd87 | ||
|
|
95ef7d4508 | ||
|
|
9812e5be6a | ||
|
|
183182943d | ||
|
|
a0189d65de | ||
|
|
b59f741162 | ||
|
|
efc2e826a1 | ||
|
|
a3ad23e262 | ||
|
|
5e3bcbfaac | ||
|
|
7f3e4558b9 | ||
|
|
567a01c2ed | ||
|
|
2236cf146e | ||
|
|
8e2f33ba1e | ||
|
|
8190883a71 | ||
|
|
c01218a97a | ||
|
|
2437817a41 | ||
|
|
682ee4529e | ||
|
|
cee520f0b5 | ||
|
|
0d915a3efc | ||
|
|
f3a562006a | ||
|
|
d78091cc60 | ||
|
|
f785c4e909 | ||
|
|
cda66ba737 | ||
|
|
ea68ffc5a4 | ||
|
|
31b0b721c8 | ||
|
|
b97e33f5d5 | ||
|
|
29e55d3664 | ||
|
|
9112f27dc0 | ||
|
|
9e67df26b3 | ||
|
|
37d1a577ef | ||
|
|
1eebb31004 | ||
|
|
885764ea1c | ||
|
|
b3d184b5c7 | ||
|
|
96d04ec17e | ||
|
|
e0bb3ad609 | ||
|
|
1a8842cb81 | ||
|
|
092d526749 | ||
|
|
9db95c188a | ||
|
|
0e45fc7d66 | ||
|
|
4d1ddbfa2b | ||
|
|
caa1c6f1bd | ||
|
|
10d686b415 | ||
|
|
29fae90da5 | ||
|
|
e27337da85 | ||
|
|
8f22316869 | ||
|
|
dd10d3e037 | ||
|
|
4a53c62af8 | ||
|
|
1ebbf2b693 | ||
|
|
62d198111c | ||
|
|
1fc0ab71aa | ||
|
|
f4402a1633 | ||
|
|
13a17bcb34 | ||
|
|
e1b49d90c2 | ||
|
|
85ab25ea16 | ||
|
|
80131ddfa8 | ||
|
|
e9c123459f | ||
|
|
d3e4bb7219 | ||
|
|
fd98d38125 | ||
|
|
3237611034 | ||
|
|
ce2bffda15 | ||
|
|
977e7b7adc | ||
|
|
5082078527 | ||
|
|
3615091c93 | ||
|
|
fb1eb44d82 | ||
|
|
13910d44bf | ||
|
|
cda1d15070 | ||
|
|
d0a1de23a6 | ||
|
|
44fd75220f | ||
|
|
ed594d653f | ||
|
|
40bb3a7581 | ||
|
|
df7f0345e8 | ||
|
|
f7ab76bb9a | ||
|
|
45e24bfa65 | ||
|
|
8cd149783c | ||
|
|
8e8e6e48a9 | ||
|
|
816e0d503a | ||
|
|
c43acd50f4 | ||
|
|
16ce4296a2 | ||
|
|
65386b753f | ||
|
|
2be1529cb8 | ||
|
|
98f8e032e3 | ||
|
|
900b785789 | ||
|
|
9194088947 | ||
|
|
58c40cbef6 | ||
|
|
e6c57dfc80 | ||
|
|
82f76f60bd | ||
|
|
b9af4aec6b | ||
|
|
f71ce27248 | ||
|
|
5b2b1765bc | ||
|
|
2a892544c2 | ||
|
|
bedb37ca6b | ||
|
|
a456cd645f | ||
|
|
9c68094cf6 | ||
|
|
379cef9e35 | ||
|
|
cb3e2dab71 | ||
|
|
3e89f83e0b | ||
|
|
af0bdd890a | ||
|
|
f93f5d0e71 | ||
|
|
667672a20b | ||
|
|
9e1f899274 | ||
|
|
75e0741665 | ||
|
|
392d0e929b | ||
|
|
b342073ba9 | ||
|
|
ff4e550ba3 | ||
|
|
17aa544be5 | ||
|
|
390676dbc4 | ||
|
|
d423252bc7 | ||
|
|
790e887b70 | ||
|
|
47e377683e | ||
|
|
b1232c0d8d | ||
|
|
059233c111 | ||
|
|
55382d000b | ||
|
|
75ab6eec43 | ||
|
|
e30171746b | ||
|
|
73849b7468 | ||
|
|
a52713611c | ||
|
|
85a66c663c | ||
|
|
e478e68b70 | ||
|
|
16095c319a | ||
|
|
f4a6100fba | ||
|
|
82060dd242 | ||
|
|
a58cfb797c | ||
|
|
c8256a50f4 | ||
|
|
3ae974e9e2 | ||
|
|
ac5e74a375 | ||
|
|
05e3d3b779 | ||
|
|
681a1ecff5 | ||
|
|
2b411b0bf9 | ||
|
|
fee16847d3 | ||
|
|
501a52a3c6 | ||
|
|
2bb014fda5 | ||
|
|
09203f67b2 | ||
|
|
169c7ec004 | ||
|
|
202e94615e | ||
|
|
5fe2a815ad | ||
|
|
a13a0b4770 | ||
|
|
455bbc457b | ||
|
|
d50fd3b580 | ||
|
|
455e80b07c | ||
|
|
291becbdf9 | ||
|
|
33385b46a7 | ||
|
|
df17668369 | ||
|
|
43449c85bb | ||
|
|
9e86eda05a | ||
|
|
b288554d9c | ||
|
|
bee55d08fb | ||
|
|
7a542aeb38 | ||
|
|
8d42513ba8 | ||
|
|
89b7247aa2 | ||
|
|
29132e7f4c | ||
|
|
3fd9baf78e | ||
|
|
f3aa3757ce | ||
|
|
3760967f59 | ||
|
|
f7ab8e0f7f | ||
|
|
0e46ea12b2 | ||
|
|
be226b2b01 | ||
|
|
9e1239e192 | ||
|
|
2eba3d85b0 | ||
|
|
9b569268ab | ||
|
|
31f5033dca | ||
|
|
78d9c60be5 | ||
|
|
baa86f09e5 | ||
|
|
a4c4b39ba8 | ||
|
|
752068bb56 | ||
|
|
739cfbb273 | ||
|
|
115af4cadf | ||
|
|
ae3274e559 | ||
|
|
c61f096dbd | ||
|
|
ee7b5c42fd | ||
|
|
85d527bfbc | ||
|
|
dd561da819 | ||
|
|
cb5932cb8b | ||
|
|
8630adc54a | ||
|
|
90d8832cd2 | ||
|
|
3802b97bb6 | ||
|
|
2de175e181 | ||
|
|
6b7d437b00 | ||
|
|
e2faf906de | ||
|
|
bb44ce5cd2 | ||
|
|
15544ae589 | ||
|
|
e421284471 | ||
|
|
785dc64787 | ||
|
|
7e7e3a7876 | ||
|
|
2b45c059e0 | ||
|
|
14ec61f9bd | ||
|
|
5cc72756f8 | ||
|
|
44785ef3e2 | ||
|
|
e60d858feb | ||
|
|
b31ecfefcd | ||
|
|
c342231052 | ||
|
|
673666837e | ||
|
|
c8f74d6c0d | ||
|
|
7ed9de8014 | ||
|
|
8650947f04 | ||
|
|
a0ac8ced31 | ||
|
|
2145bbea81 | ||
|
|
480000ee7f | ||
|
|
9ec2ad022e | ||
|
|
43e40816dc | ||
|
|
941ea3ee68 | ||
|
|
a6e4b5159e | ||
|
|
6f542d58d5 | ||
|
|
b2b5fcee7d | ||
|
|
59a82345a9 | ||
|
|
b61a747876 | ||
|
|
72e5d800d5 | ||
|
|
c7aa6d4804 | ||
|
|
b31063449d | ||
|
|
477672459d | ||
|
|
9c33897296 | ||
|
|
100cfb57c5 | ||
|
|
40b34071e7 | ||
|
|
341833fd8f | ||
|
|
f647fd6fea | ||
|
|
53642f2389 | ||
|
|
b9bdd655ab | ||
|
|
e9e1b5b54f | ||
|
|
be2163d635 | ||
|
|
7f6dde3a5f | ||
|
|
334aafee23 | ||
|
|
1a20c18b19 | ||
|
|
6e655b165c | ||
|
|
d768b2fa1e | ||
|
|
85bce1cfba | ||
|
|
a798a2466f | ||
|
|
2a5d8a5c82 | ||
|
|
ea62171d98 | ||
|
|
196389d5ee | ||
|
|
1776021620 | ||
|
|
c42a9124d3 | ||
|
|
a44647b4cd | ||
|
|
e0c3fd87c5 | ||
|
|
ed8f2a85b7 | ||
|
|
48f8553c75 | ||
|
|
af4517fd1e | ||
|
|
78e6a46318 | ||
|
|
49ca923e51 | ||
|
|
7ad22e0399 | ||
|
|
bb8acc6065 | ||
|
|
c0fa4a19e9 | ||
|
|
3f1741dd18 | ||
|
|
9ef02e4110 | ||
|
|
15a6f38ebb | ||
|
|
227f2e5a21 | ||
|
|
517d6ee981 | ||
|
|
184eeb7f49 | ||
|
|
a3555c74e8 | ||
|
|
657bafd458 | ||
|
|
2ad5df420c | ||
|
|
b24d489ec5 | ||
|
|
6bb0210f1f | ||
|
|
c1de50266a | ||
|
|
562e02bc64 | ||
|
|
f71ec7913a | ||
|
|
d98baaf660 | ||
|
|
72db591576 | ||
|
|
509a37fc04 | ||
|
|
17f62b6e86 | ||
|
|
b09aee7644 | ||
|
|
babcc0de0c | ||
|
|
5cc47c9222 | ||
|
|
833559a3b3 | ||
|
|
b8a976b344 | ||
|
|
10b14132b9 | ||
|
|
18953f0b7c | ||
|
|
19f5fba3aa | ||
|
|
636bc3e61a | ||
|
|
521037e1a6 | ||
|
|
e024c3e38d | ||
|
|
6a0206c1e7 | ||
|
|
69a8a83528 | ||
|
|
0307d700fa | ||
|
|
919c383b41 | ||
|
|
d331af4d5a | ||
|
|
b5467d3c23 | ||
|
|
3ef0040d66 | ||
|
|
ec4dfd2172 | ||
|
|
8f54d7c8e9 | ||
|
|
b59e709dc0 | ||
|
|
b236e6c886 | ||
|
|
8acbb7d6f0 | ||
|
|
49e4bc9381 | ||
|
|
36106cc08d | ||
|
|
8f1763abe2 | ||
|
|
480eebc6cb | ||
|
|
dccfffd979 | ||
|
|
1a978f4762 | ||
|
|
1fbc8f4060 | ||
|
|
db3fc1421c | ||
|
|
9ecd03db0e | ||
|
|
f111ccb1b6 | ||
|
|
a32341cc5d | ||
|
|
f73e277230 | ||
|
|
8d8587ca29 | ||
|
|
88eb9511bf | ||
|
|
e1068997ea | ||
|
|
560e04c64a | ||
|
|
621ec03971 | ||
|
|
d14a47d3f7 | ||
|
|
3e9de0c210 | ||
|
|
01a6e074a5 | ||
|
|
1434077f4e | ||
|
|
3922175af1 | ||
|
|
ec6852a8d7 | ||
|
|
b0b908b4ae | ||
|
|
5a00336ef1 | ||
|
|
d53f5e21f4 | ||
|
|
bd173fa333 | ||
|
|
6b32fa31b6 | ||
|
|
d1dba89e39 | ||
|
|
b2f2806465 | ||
|
|
3b70cd58a3 | ||
|
|
6769bfd824 | ||
|
|
bff4af2534 | ||
|
|
aabf575ac5 | ||
|
|
4aacaf6bd6 | ||
|
|
268070e89c | ||
|
|
4fa2134cc6 | ||
|
|
97c35de49a | ||
|
|
32fb550969 | ||
|
|
3457441929 | ||
|
|
32f0fc7a46 | ||
|
|
c891a8f164 | ||
|
|
991764af94 | ||
|
|
1df447272e | ||
|
|
f032ae757b | ||
|
|
e529b2859e | ||
|
|
d1cb2368fa | ||
|
|
7b812184d1 | ||
|
|
4f7ce1c6ee | ||
|
|
44a5ac63db | ||
|
|
0989ee88cc | ||
|
|
ba8b72c2c8 | ||
|
|
2dbb4583f4 | ||
|
|
81ad42e029 | ||
|
|
5d3695f8ba | ||
|
|
c771694aa0 | ||
|
|
1ac0fd4c10 | ||
|
|
631ff8caef | ||
|
|
7382182132 | ||
|
|
4ff9da68ef | ||
|
|
dee2998ee3 | ||
|
|
1d43236211 | ||
|
|
792bc610a3 | ||
|
|
7a51c828c2 | ||
|
|
fab6fcd5ac | ||
|
|
c8e00ba160 | ||
|
|
6245b6d823 | ||
|
|
0c55bf20fc | ||
|
|
f8fd7b5933 | ||
|
|
6462eea2ef | ||
|
|
3d79891249 | ||
|
|
80763c4bbf | ||
|
|
59102afd45 | ||
|
|
0b085354db | ||
|
|
e2a473baa3 | ||
|
|
06e10fdd3c | ||
|
|
fb4386a7ad | ||
|
|
2d294f6841 | ||
|
|
e09a839148 |
@@ -1,49 +0,0 @@
|
|||||||
FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.8
|
|
||||||
|
|
||||||
WORKDIR /workspaces
|
|
||||||
|
|
||||||
# Install Node/Yarn for Frontent
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
apt-utils \
|
|
||||||
apt-transport-https \
|
|
||||||
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
|
|
||||||
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list \
|
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
nodejs \
|
|
||||||
yarn \
|
|
||||||
&& curl -o - https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
ENV NVM_DIR /root/.nvm
|
|
||||||
|
|
||||||
# Install docker
|
|
||||||
# https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
apt-transport-https \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
software-properties-common \
|
|
||||||
gpg-agent \
|
|
||||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \
|
|
||||||
&& add-apt-repository "deb https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
|
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
docker-ce \
|
|
||||||
docker-ce-cli \
|
|
||||||
containerd.io \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install tools
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
jq \
|
|
||||||
dbus \
|
|
||||||
network-manager \
|
|
||||||
libpulse0 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Python dependencies from requirements.txt if it exists
|
|
||||||
COPY requirements.txt requirements_tests.txt ./
|
|
||||||
RUN pip3 install -U setuptools pip \
|
|
||||||
&& pip3 install -r requirements.txt -r requirements_tests.txt \
|
|
||||||
&& pip3 install tox \
|
|
||||||
&& rm -f requirements.txt requirements_tests.txt
|
|
||||||
@@ -1,32 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "Supervisor dev",
|
"name": "Supervisor dev",
|
||||||
"context": "..",
|
"image": "ghcr.io/home-assistant/devcontainer:supervisor",
|
||||||
"dockerFile": "Dockerfile",
|
"containerEnv": {
|
||||||
"appPort": "9123:8123",
|
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}"
|
||||||
"postCreateCommand": "pre-commit install",
|
},
|
||||||
|
"remoteEnv": {
|
||||||
|
"PATH": "${containerEnv:VIRTUAL_ENV}/bin:${containerEnv:PATH}"
|
||||||
|
},
|
||||||
|
"appPort": ["9123:8123", "7357:4357"],
|
||||||
|
"postCreateCommand": "bash devcontainer_setup",
|
||||||
|
"postStartCommand": "bash devcontainer_bootstrap",
|
||||||
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"ms-python.python",
|
"charliermarsh.ruff",
|
||||||
|
"ms-python.pylint",
|
||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
"visualstudioexptteam.vscodeintellicode",
|
"visualstudioexptteam.vscodeintellicode",
|
||||||
"esbenp.prettier-vscode"
|
"redhat.vscode-yaml",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"GitHub.vscode-pull-request-github"
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"terminal.integrated.shell.linux": "/bin/bash",
|
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
|
||||||
|
"python.pythonPath": "/home/vscode/.local/ha-venv/bin/python",
|
||||||
|
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||||
|
"python.testing.pytestArgs": ["--no-cov"],
|
||||||
|
"pylint.importStrategy": "fromEnvironment",
|
||||||
"editor.formatOnPaste": false,
|
"editor.formatOnPaste": false,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnType": true,
|
"editor.formatOnType": true,
|
||||||
"files.trimTrailingWhitespace": true,
|
"files.trimTrailingWhitespace": true,
|
||||||
"python.pythonPath": "/usr/local/bin/python3",
|
"terminal.integrated.profiles.linux": {
|
||||||
"python.linting.pylintEnabled": true,
|
"zsh": {
|
||||||
"python.linting.enabled": true,
|
"path": "/usr/bin/zsh"
|
||||||
"python.formatting.provider": "black",
|
}
|
||||||
"python.formatting.blackArgs": ["--target-version", "py38"],
|
},
|
||||||
"python.formatting.blackPath": "/usr/local/bin/black",
|
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||||
"python.linting.banditPath": "/usr/local/bin/bandit",
|
"[python]": {
|
||||||
"python.linting.flake8Path": "/usr/local/bin/flake8",
|
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||||
"python.linting.mypyPath": "/usr/local/bin/mypy",
|
|
||||||
"python.linting.pylintPath": "/usr/local/bin/pylint",
|
|
||||||
"python.linting.pydocstylePath": "/usr/local/bin/pydocstyle"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mounts": ["type=volume,target=/var/lib/docker"]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
name: Report a bug with the Supervisor
|
name: Report a bug with the Supervisor on a supported System
|
||||||
about: Report an issue related to the Home Assistant Supervisor.
|
about: Report an issue related to the Home Assistant Supervisor.
|
||||||
labels: bug
|
labels: bug
|
||||||
---
|
---
|
||||||
@@ -11,6 +11,10 @@ labels: bug
|
|||||||
- If you have a problem with an add-on, make an issue in it's repository.
|
- If you have a problem with an add-on, make an issue in it's repository.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Important: You can only fill a bug repport for an supported system! If you run an unsupported installation. This report would be closed without comment.
|
||||||
|
-->
|
||||||
|
|
||||||
### Describe the issue
|
### Describe the issue
|
||||||
|
|
||||||
<!-- Provide as many details as possible. -->
|
<!-- Provide as many details as possible. -->
|
||||||
@@ -47,3 +51,19 @@ Paste supervisor logs here
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### System Information
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>System Information</summary>
|
||||||
|
<!--
|
||||||
|
- Use this command: ha info
|
||||||
|
-->
|
||||||
|
|
||||||
|
```
|
||||||
|
Paste system info here
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
96
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
96
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
name: Bug Report Form
|
||||||
|
description: Report an issue related to the Home Assistant Supervisor.
|
||||||
|
labels: bug
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
This issue form is for reporting bugs with **supported** setups only!
|
||||||
|
|
||||||
|
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
|
||||||
|
|
||||||
|
[fr]: https://community.home-assistant.io/c/feature-requests
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Describe the issue you are experiencing
|
||||||
|
description: Provide a clear and concise description of what the bug is.
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Environment
|
||||||
|
- type: dropdown
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: What type of installation are you running?
|
||||||
|
description: >
|
||||||
|
If you don't know, can be found in [Settings -> System -> Repairs -> System Information](https://my.home-assistant.io/redirect/system_health/).
|
||||||
|
It is listed as the `Installation Type` value.
|
||||||
|
options:
|
||||||
|
- Home Assistant OS
|
||||||
|
- Home Assistant Supervised
|
||||||
|
- type: dropdown
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Which operating system are you running on?
|
||||||
|
options:
|
||||||
|
- Home Assistant Operating System
|
||||||
|
- Debian
|
||||||
|
- Other (e.g., Raspbian/Raspberry Pi OS/Fedora)
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
# Details
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce the issue
|
||||||
|
description: |
|
||||||
|
Please tell us exactly how to reproduce your issue.
|
||||||
|
Provide clear and concise step by step instructions and add code snippets if needed.
|
||||||
|
value: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
...
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: Anything in the Supervisor logs that might be useful for us?
|
||||||
|
description: >
|
||||||
|
Supervisor Logs can be found in [Settings -> System -> Logs](https://my.home-assistant.io/redirect/logs/)
|
||||||
|
then choose `Supervisor` in the top right.
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/supervisor_logs/)
|
||||||
|
render: txt
|
||||||
|
- type: textarea
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: System Health information
|
||||||
|
description: >
|
||||||
|
System Health information can be found in the top right menu in [Settings -> System -> Repairs](https://my.home-assistant.io/redirect/repairs/).
|
||||||
|
Click the copy button at the bottom of the pop-up and paste it here.
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/system_health/)
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Supervisor diagnostics
|
||||||
|
placeholder: "drag-and-drop the diagnostics data file here (do not copy-and-paste the content)"
|
||||||
|
description: >-
|
||||||
|
Supervisor diagnostics can be found in [Settings -> Integrations](https://my.home-assistant.io/redirect/integrations/).
|
||||||
|
Find the card that says `Home Assistant Supervisor`, open its menu and select 'Download diagnostics'.
|
||||||
|
|
||||||
|
**Please drag-and-drop the downloaded file into the textbox below. Do not copy and paste its contents.**
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: >
|
||||||
|
If you have any additional information for us, use the field below.
|
||||||
|
Please note, you can attach screenshots or screen recordings here, by
|
||||||
|
dragging and dropping files in the field below.
|
||||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,9 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
|
- name: Report a bug/issues with an unsupported Supervisor
|
||||||
|
url: https://community.home-assistant.io
|
||||||
|
about: The Community guide can help or was updated to solve your issue
|
||||||
|
|
||||||
- name: Report a bug for the Supervisor panel
|
- name: Report a bug for the Supervisor panel
|
||||||
url: https://github.com/home-assistant/frontend/issues
|
url: https://github.com/home-assistant/frontend/issues
|
||||||
about: The Supervisor panel is a part of the Home Assistant frontend
|
about: The Supervisor panel is a part of the Home Assistant frontend
|
||||||
|
|||||||
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
10
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -37,6 +37,8 @@
|
|||||||
- This PR fixes or closes issue: fixes #
|
- This PR fixes or closes issue: fixes #
|
||||||
- This PR is related to issue:
|
- This PR is related to issue:
|
||||||
- Link to documentation pull request:
|
- Link to documentation pull request:
|
||||||
|
- Link to cli pull request:
|
||||||
|
- Link to client library pull request:
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
@@ -51,12 +53,14 @@
|
|||||||
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
|
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
|
||||||
- [ ] There is no commented out code in this PR.
|
- [ ] There is no commented out code in this PR.
|
||||||
- [ ] I have followed the [development checklist][dev-checklist]
|
- [ ] I have followed the [development checklist][dev-checklist]
|
||||||
- [ ] The code has been formatted using Black (`black --fast supervisor tests`)
|
- [ ] The code has been formatted using Ruff (`ruff format supervisor tests`)
|
||||||
- [ ] Tests have been added to verify that the new code works.
|
- [ ] Tests have been added to verify that the new code works.
|
||||||
|
|
||||||
If API endpoints of add-on configuration are added/changed:
|
If API endpoints or add-on configuration are added/changed:
|
||||||
|
|
||||||
- [ ] Documentation added/updated for [developers.home-assistant.io][docs-repository]
|
- [ ] Documentation added/updated for [developers.home-assistant.io][docs-repository]
|
||||||
|
- [ ] [CLI][cli-repository] updated (if necessary)
|
||||||
|
- [ ] [Client library][client-library-repository] updated (if necessary)
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Thank you for contributing <3
|
Thank you for contributing <3
|
||||||
@@ -66,3 +70,5 @@ If API endpoints of add-on configuration are added/changed:
|
|||||||
|
|
||||||
[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html
|
[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html
|
||||||
[docs-repository]: https://github.com/home-assistant/developers.home-assistant
|
[docs-repository]: https://github.com/home-assistant/developers.home-assistant
|
||||||
|
[cli-repository]: https://github.com/home-assistant/cli
|
||||||
|
[client-library-repository]: https://github.com/home-assistant-libs/python-supervisor-client/
|
||||||
|
|||||||
48
.github/release-drafter.yml
vendored
48
.github/release-drafter.yml
vendored
@@ -1,4 +1,50 @@
|
|||||||
|
change-template: "- #$NUMBER $TITLE @$AUTHOR"
|
||||||
|
sort-direction: ascending
|
||||||
|
|
||||||
|
categories:
|
||||||
|
- title: ":boom: Breaking Changes"
|
||||||
|
label: "breaking-change"
|
||||||
|
|
||||||
|
- title: ":wrench: Build"
|
||||||
|
label: "build"
|
||||||
|
|
||||||
|
- title: ":boar: Chore"
|
||||||
|
label: "chore"
|
||||||
|
|
||||||
|
- title: ":sparkles: New Features"
|
||||||
|
label: "new-feature"
|
||||||
|
|
||||||
|
- title: ":zap: Performance"
|
||||||
|
label: "performance"
|
||||||
|
|
||||||
|
- title: ":recycle: Refactor"
|
||||||
|
label: "refactor"
|
||||||
|
|
||||||
|
- title: ":green_heart: CI"
|
||||||
|
label: "ci"
|
||||||
|
|
||||||
|
- title: ":bug: Bug Fixes"
|
||||||
|
label: "bugfix"
|
||||||
|
|
||||||
|
- title: ":white_check_mark: Test"
|
||||||
|
label: "test"
|
||||||
|
|
||||||
|
- title: ":arrow_up: Dependency Updates"
|
||||||
|
label: "dependencies"
|
||||||
|
collapse-after: 1
|
||||||
|
|
||||||
|
include-labels:
|
||||||
|
- "breaking-change"
|
||||||
|
- "build"
|
||||||
|
- "chore"
|
||||||
|
- "performance"
|
||||||
|
- "refactor"
|
||||||
|
- "new-feature"
|
||||||
|
- "bugfix"
|
||||||
|
- "dependencies"
|
||||||
|
- "test"
|
||||||
|
- "ci"
|
||||||
|
|
||||||
template: |
|
template: |
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
$CHANGES
|
$CHANGES
|
||||||
|
|||||||
230
.github/workflows/builder.yml
vendored
230
.github/workflows/builder.yml
vendored
@@ -27,15 +27,20 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "rootfs/**"
|
- "rootfs/**"
|
||||||
- "supervisor/**"
|
- "supervisor/**"
|
||||||
- build.json
|
- build.yaml
|
||||||
- Dockerfile
|
- Dockerfile
|
||||||
- requirements.txt
|
- requirements.txt
|
||||||
- setup.py
|
- setup.py
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
DEFAULT_PYTHON: "3.12"
|
||||||
BUILD_NAME: supervisor
|
BUILD_NAME: supervisor
|
||||||
BUILD_TYPE: supervisor
|
BUILD_TYPE: supervisor
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
init:
|
init:
|
||||||
name: Initialize build
|
name: Initialize build
|
||||||
@@ -45,9 +50,10 @@ jobs:
|
|||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
channel: ${{ steps.version.outputs.channel }}
|
channel: ${{ steps.version.outputs.channel }}
|
||||||
publish: ${{ steps.version.outputs.publish }}
|
publish: ${{ steps.version.outputs.publish }}
|
||||||
|
requirements: ${{ steps.requirements.outputs.changed }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -61,44 +67,109 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
type: ${{ env.BUILD_TYPE }}
|
type: ${{ env.BUILD_TYPE }}
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed_files
|
||||||
|
if: steps.version.outputs.publish == 'false'
|
||||||
|
uses: masesgroup/retrieve-changed-files@v3.0.0
|
||||||
|
|
||||||
|
- name: Check if requirements files changed
|
||||||
|
id: requirements
|
||||||
|
run: |
|
||||||
|
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.yaml) ]]; then
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build ${{ matrix.arch }} supervisor
|
name: Build ${{ matrix.arch }} supervisor
|
||||||
needs: init
|
needs: init
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
packages: write
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Write env-file
|
||||||
|
if: needs.init.outputs.requirements == 'true'
|
||||||
|
run: |
|
||||||
|
(
|
||||||
|
# Fix out of memory issues with rust
|
||||||
|
echo "CARGO_NET_GIT_FETCH_WITH_CLI=true"
|
||||||
|
) > .env_file
|
||||||
|
|
||||||
|
- name: Build wheels
|
||||||
|
if: needs.init.outputs.requirements == 'true'
|
||||||
|
uses: home-assistant/wheels@2024.07.1
|
||||||
|
with:
|
||||||
|
abi: cp312
|
||||||
|
tag: musllinux_1_2
|
||||||
|
arch: ${{ matrix.arch }}
|
||||||
|
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||||
|
apk: "libffi-dev;openssl-dev;yaml-dev"
|
||||||
|
skip-binary: aiohttp
|
||||||
|
env-file: true
|
||||||
|
requirements: "requirements.txt"
|
||||||
|
|
||||||
- name: Set version
|
- name: Set version
|
||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
uses: home-assistant/actions/helpers/version@master
|
uses: home-assistant/actions/helpers/version@master
|
||||||
with:
|
with:
|
||||||
type: ${{ env.BUILD_TYPE }}
|
type: ${{ env.BUILD_TYPE }}
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
uses: docker/login-action@v1
|
uses: actions/setup-python@v5.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
- name: Install Cosign
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
uses: sigstore/cosign-installer@v3.7.0
|
||||||
|
with:
|
||||||
|
cosign-release: "v2.4.0"
|
||||||
|
|
||||||
|
- name: Install dirhash and calc hash
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
run: |
|
||||||
|
pip3 install setuptools dirhash
|
||||||
|
dir_hash="$(dirhash "${{ github.workspace }}/supervisor" -a sha256 --match "*.py")"
|
||||||
|
echo "${dir_hash}" > rootfs/supervisor.sha256
|
||||||
|
|
||||||
|
- name: Sign supervisor SHA256
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
run: |
|
||||||
|
cosign sign-blob --yes rootfs/supervisor.sha256 --bundle rootfs/supervisor.sha256.sig
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
uses: docker/login-action@v3.3.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set build arguments
|
- name: Set build arguments
|
||||||
if: needs.init.outputs.publish == 'false'
|
if: needs.init.outputs.publish == 'false'
|
||||||
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
|
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build supervisor
|
- name: Build supervisor
|
||||||
uses: home-assistant/builder@2020.11.0
|
uses: home-assistant/builder@2024.08.2
|
||||||
with:
|
with:
|
||||||
args: |
|
args: |
|
||||||
$BUILD_ARGS \
|
$BUILD_ARGS \
|
||||||
--${{ matrix.arch }} \
|
--${{ matrix.arch }} \
|
||||||
--target /data \
|
--target /data \
|
||||||
|
--cosign \
|
||||||
--generic ${{ needs.init.outputs.version }}
|
--generic ${{ needs.init.outputs.version }}
|
||||||
|
env:
|
||||||
|
CAS_API_KEY: ${{ secrets.CAS_TOKEN }}
|
||||||
|
|
||||||
version:
|
version:
|
||||||
name: Update version
|
name: Update version
|
||||||
@@ -107,7 +178,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
|
|
||||||
- name: Initialize git
|
- name: Initialize git
|
||||||
if: needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.publish == 'true'
|
||||||
@@ -128,13 +199,15 @@ jobs:
|
|||||||
run_supervisor:
|
run_supervisor:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Run the Supervisor
|
name: Run the Supervisor
|
||||||
needs: ["build"]
|
needs: ["build", "init"]
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
|
|
||||||
- name: Build the Supervisor
|
- name: Build the Supervisor
|
||||||
uses: home-assistant/builder@2020.11.0
|
if: needs.init.outputs.publish != 'true'
|
||||||
|
uses: home-assistant/builder@2024.08.2
|
||||||
with:
|
with:
|
||||||
args: |
|
args: |
|
||||||
--test \
|
--test \
|
||||||
@@ -142,13 +215,19 @@ jobs:
|
|||||||
--target /data \
|
--target /data \
|
||||||
--generic runner
|
--generic runner
|
||||||
|
|
||||||
|
- name: Pull Supervisor
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
run: |
|
||||||
|
docker pull ghcr.io/home-assistant/amd64-hassio-supervisor:${{ needs.init.outputs.version }}
|
||||||
|
docker tag ghcr.io/home-assistant/amd64-hassio-supervisor:${{ needs.init.outputs.version }} ghcr.io/home-assistant/amd64-hassio-supervisor:runner
|
||||||
|
|
||||||
- name: Create the Supervisor
|
- name: Create the Supervisor
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /tmp/supervisor/data
|
mkdir -p /tmp/supervisor/data
|
||||||
docker create --name hassio_supervisor \
|
docker create --name hassio_supervisor \
|
||||||
--privileged \
|
--privileged \
|
||||||
--security-opt seccomp=unconfined \
|
--security-opt seccomp=unconfined \
|
||||||
--security-opt apparmor:unconfined \
|
--security-opt apparmor=unconfined \
|
||||||
-v /run/docker.sock:/run/docker.sock \
|
-v /run/docker.sock:/run/docker.sock \
|
||||||
-v /run/dbus:/run/dbus \
|
-v /run/dbus:/run/dbus \
|
||||||
-v /tmp/supervisor/data:/data \
|
-v /tmp/supervisor/data:/data \
|
||||||
@@ -157,7 +236,7 @@ jobs:
|
|||||||
-e SUPERVISOR_NAME=hassio_supervisor \
|
-e SUPERVISOR_NAME=hassio_supervisor \
|
||||||
-e SUPERVISOR_DEV=1 \
|
-e SUPERVISOR_DEV=1 \
|
||||||
-e SUPERVISOR_MACHINE="qemux86-64" \
|
-e SUPERVISOR_MACHINE="qemux86-64" \
|
||||||
homeassistant/amd64-hassio-supervisor:runner
|
ghcr.io/home-assistant/amd64-hassio-supervisor:runner
|
||||||
|
|
||||||
- name: Start the Supervisor
|
- name: Start the Supervisor
|
||||||
run: docker start hassio_supervisor
|
run: docker start hassio_supervisor
|
||||||
@@ -167,22 +246,135 @@ jobs:
|
|||||||
SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.IPAddress}}' hassio_supervisor)
|
SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.IPAddress}}' hassio_supervisor)
|
||||||
ping="error"
|
ping="error"
|
||||||
while [ "$ping" != "ok" ]; do
|
while [ "$ping" != "ok" ]; do
|
||||||
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r .result)
|
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r '.result')
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Check the Supervisor
|
- name: Check the Supervisor
|
||||||
run: |
|
run: |
|
||||||
echo "Checking supervisor info"
|
echo "Checking supervisor info"
|
||||||
test=$(docker exec hassio_cli ha supervisor info --no-progress --raw-json | jq -r .result)
|
test=$(docker exec hassio_cli ha supervisor info --no-progress --raw-json | jq -r '.result')
|
||||||
if [ "$test" != "ok" ]; then
|
if [ "$test" != "ok" ]; then
|
||||||
docker logs hassio_supervisor
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Checking supervisor network info"
|
echo "Checking supervisor network info"
|
||||||
test=$(docker exec hassio_cli ha network info --no-progress --raw-json | jq -r .result)
|
test=$(docker exec hassio_cli ha network info --no-progress --raw-json | jq -r '.result')
|
||||||
if [ "$test" != "ok" ]; then
|
if [ "$test" != "ok" ]; then
|
||||||
docker logs hassio_supervisor
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Check the Store / Addon
|
||||||
|
run: |
|
||||||
|
echo "Install Core SSH Add-on"
|
||||||
|
test=$(docker exec hassio_cli ha addons install core_ssh --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure it actually installed
|
||||||
|
test=$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.version')
|
||||||
|
if [[ "$test" == "null" ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Start Core SSH Add-on"
|
||||||
|
test=$(docker exec hassio_cli ha addons start core_ssh --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure its state is started
|
||||||
|
test="$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.state')"
|
||||||
|
if [ "$test" != "started" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check the Supervisor code sign
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
run: |
|
||||||
|
echo "Enable Content-Trust"
|
||||||
|
test=$(docker exec hassio_cli ha security options --content-trust=true --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Run supervisor health check"
|
||||||
|
test=$(docker exec hassio_cli ha resolution healthcheck --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Check supervisor unhealthy"
|
||||||
|
test=$(docker exec hassio_cli ha resolution info --no-progress --raw-json | jq -r '.data.unhealthy[]')
|
||||||
|
if [ "$test" != "" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Check supervisor supported"
|
||||||
|
test=$(docker exec hassio_cli ha resolution info --no-progress --raw-json | jq -r '.data.unsupported[]')
|
||||||
|
if [[ "$test" =~ source_mods ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create full backup
|
||||||
|
id: backup
|
||||||
|
run: |
|
||||||
|
test=$(docker exec hassio_cli ha backups new --no-progress --raw-json)
|
||||||
|
if [ "$(echo $test | jq -r '.result')" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "slug=$(echo $test | jq -r '.data.slug')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Uninstall SSH add-on
|
||||||
|
run: |
|
||||||
|
test=$(docker exec hassio_cli ha addons uninstall core_ssh --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Restart supervisor
|
||||||
|
run: |
|
||||||
|
test=$(docker exec hassio_cli ha supervisor restart --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Wait for Supervisor to come up
|
||||||
|
run: |
|
||||||
|
SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.IPAddress}}' hassio_supervisor)
|
||||||
|
ping="error"
|
||||||
|
while [ "$ping" != "ok" ]; do
|
||||||
|
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r '.result')
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Restore SSH add-on from backup
|
||||||
|
run: |
|
||||||
|
test=$(docker exec hassio_cli ha backups restore ${{ steps.backup.outputs.slug }} --addons core_ssh --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure it actually installed
|
||||||
|
test=$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.version')
|
||||||
|
if [[ "$test" == "null" ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure its state is started
|
||||||
|
test="$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.state')"
|
||||||
|
if [ "$test" != "started" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Restore SSL directory from backup
|
||||||
|
run: |
|
||||||
|
test=$(docker exec hassio_cli ha backups restore ${{ steps.backup.outputs.slug }} --folders ssl --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Get supervisor logs on failiure
|
||||||
|
if: ${{ cancelled() || failure() }}
|
||||||
|
run: docker logs hassio_supervisor
|
||||||
|
|||||||
19
.github/workflows/check_pr_labels.yml
vendored
Normal file
19
.github/workflows/check_pr_labels.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
name: Check PR
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: ["main"]
|
||||||
|
types: [labeled, unlabeled, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
init:
|
||||||
|
name: Check labels
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check labels
|
||||||
|
run: |
|
||||||
|
labels=$(jq -r '.pull_request.labels[] | .name' ${{github.event_path }})
|
||||||
|
echo "$labels"
|
||||||
|
if [ "$labels" == "cla-signed" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
314
.github/workflows/ci.yaml
vendored
314
.github/workflows/ci.yaml
vendored
@@ -8,36 +8,36 @@ on:
|
|||||||
pull_request: ~
|
pull_request: ~
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DEFAULT_PYTHON: 3.8
|
DEFAULT_PYTHON: "3.12"
|
||||||
PRE_COMMIT_HOME: ~/.cache/pre-commit
|
PRE_COMMIT_CACHE: ~/.cache/pre-commit
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Separate job to pre-populate the base dependency cache
|
# Separate job to pre-populate the base dependency cache
|
||||||
# This prevent upcoming jobs to do the same individually
|
# This prevent upcoming jobs to do the same individually
|
||||||
prepare:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
outputs:
|
||||||
matrix:
|
python-version: ${{ steps.python.outputs.python-version }}
|
||||||
python-version: [3.8]
|
name: Prepare Python dependencies
|
||||||
name: Prepare Python ${{ matrix.python-version }} dependencies
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v2.1.4
|
uses: actions/setup-python@v5.2.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}
|
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-
|
|
||||||
- name: Create Python virtual environment
|
- name: Create Python virtual environment
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -47,9 +47,10 @@ jobs:
|
|||||||
pip install -r requirements.txt -r requirements_tests.txt
|
pip install -r requirements.txt -r requirements_tests.txt
|
||||||
- name: Restore pre-commit environment from cache
|
- name: Restore pre-commit environment from cache
|
||||||
id: cache-precommit
|
id: cache-precommit
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PRE_COMMIT_HOME }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
|
lookup-only: true
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
@@ -60,34 +61,91 @@ jobs:
|
|||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
pre-commit install-hooks
|
pre-commit install-hooks
|
||||||
|
|
||||||
lint-black:
|
lint-ruff-format:
|
||||||
name: Check black
|
name: Check ruff-format
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@v2.1.4
|
uses: actions/setup-python@v5.2.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||||
- name: Fail job if Python cache restore failed
|
- name: Fail job if Python cache restore failed
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Failed to restore Python virtual environment from cache"
|
echo "Failed to restore Python virtual environment from cache"
|
||||||
exit 1
|
exit 1
|
||||||
- name: Run black
|
- name: Restore pre-commit environment from cache
|
||||||
|
id: cache-precommit
|
||||||
|
uses: actions/cache@v4.1.1
|
||||||
|
with:
|
||||||
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
|
key: |
|
||||||
|
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
- name: Fail job if cache restore failed
|
||||||
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
echo "Failed to restore Python virtual environment from cache"
|
||||||
|
exit 1
|
||||||
|
- name: Run ruff-format
|
||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
black --target-version py38 --check supervisor tests setup.py
|
pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure
|
||||||
|
env:
|
||||||
|
RUFF_OUTPUT_FORMAT: github
|
||||||
|
|
||||||
|
lint-ruff:
|
||||||
|
name: Check ruff
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: prepare
|
||||||
|
steps:
|
||||||
|
- name: Check out code from GitHub
|
||||||
|
uses: actions/checkout@v4.2.1
|
||||||
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
|
uses: actions/setup-python@v5.2.0
|
||||||
|
id: python
|
||||||
|
with:
|
||||||
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
|
- name: Restore Python virtual environment
|
||||||
|
id: cache-venv
|
||||||
|
uses: actions/cache@v4.1.1
|
||||||
|
with:
|
||||||
|
path: venv
|
||||||
|
key: |
|
||||||
|
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||||
|
- name: Fail job if Python cache restore failed
|
||||||
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
echo "Failed to restore Python virtual environment from cache"
|
||||||
|
exit 1
|
||||||
|
- name: Restore pre-commit environment from cache
|
||||||
|
id: cache-precommit
|
||||||
|
uses: actions/cache@v4.1.1
|
||||||
|
with:
|
||||||
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
|
key: |
|
||||||
|
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
- name: Fail job if cache restore failed
|
||||||
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
echo "Failed to restore Python virtual environment from cache"
|
||||||
|
exit 1
|
||||||
|
- name: Run ruff
|
||||||
|
run: |
|
||||||
|
. venv/bin/activate
|
||||||
|
pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure
|
||||||
|
env:
|
||||||
|
RUFF_OUTPUT_FORMAT: github
|
||||||
|
|
||||||
lint-dockerfile:
|
lint-dockerfile:
|
||||||
name: Check Dockerfile
|
name: Check Dockerfile
|
||||||
@@ -95,7 +153,7 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Register hadolint problem matcher
|
- name: Register hadolint problem matcher
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||||
@@ -110,19 +168,19 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@v2.1.4
|
uses: actions/setup-python@v5.2.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||||
- name: Fail job if Python cache restore failed
|
- name: Fail job if Python cache restore failed
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -130,9 +188,9 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
- name: Restore pre-commit environment from cache
|
- name: Restore pre-commit environment from cache
|
||||||
id: cache-precommit
|
id: cache-precommit
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PRE_COMMIT_HOME }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
- name: Fail job if cache restore failed
|
- name: Fail job if cache restore failed
|
||||||
@@ -148,98 +206,25 @@ jobs:
|
|||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
pre-commit run --hook-stage manual check-executables-have-shebangs --all-files
|
pre-commit run --hook-stage manual check-executables-have-shebangs --all-files
|
||||||
|
|
||||||
lint-flake8:
|
|
||||||
name: Check flake8
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: prepare
|
|
||||||
steps:
|
|
||||||
- name: Check out code from GitHub
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
|
||||||
uses: actions/setup-python@v2.1.4
|
|
||||||
id: python
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
|
||||||
- name: Restore Python virtual environment
|
|
||||||
id: cache-venv
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: venv
|
|
||||||
key: |
|
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
|
||||||
- name: Fail job if Python cache restore failed
|
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
echo "Failed to restore Python virtual environment from cache"
|
|
||||||
exit 1
|
|
||||||
- name: Register flake8 problem matcher
|
|
||||||
run: |
|
|
||||||
echo "::add-matcher::.github/workflows/matchers/flake8.json"
|
|
||||||
- name: Run flake8
|
|
||||||
run: |
|
|
||||||
. venv/bin/activate
|
|
||||||
flake8 supervisor tests
|
|
||||||
|
|
||||||
lint-isort:
|
|
||||||
name: Check isort
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: prepare
|
|
||||||
steps:
|
|
||||||
- name: Check out code from GitHub
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
|
||||||
uses: actions/setup-python@v2.1.4
|
|
||||||
id: python
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
|
||||||
- name: Restore Python virtual environment
|
|
||||||
id: cache-venv
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: venv
|
|
||||||
key: |
|
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
|
||||||
- name: Fail job if Python cache restore failed
|
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
echo "Failed to restore Python virtual environment from cache"
|
|
||||||
exit 1
|
|
||||||
- name: Restore pre-commit environment from cache
|
|
||||||
id: cache-precommit
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: ${{ env.PRE_COMMIT_HOME }}
|
|
||||||
key: |
|
|
||||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
|
||||||
- name: Fail job if cache restore failed
|
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
echo "Failed to restore Python virtual environment from cache"
|
|
||||||
exit 1
|
|
||||||
- name: Run isort
|
|
||||||
run: |
|
|
||||||
. venv/bin/activate
|
|
||||||
pre-commit run --hook-stage manual isort --all-files --show-diff-on-failure
|
|
||||||
|
|
||||||
lint-json:
|
lint-json:
|
||||||
name: Check JSON
|
name: Check JSON
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@v2.1.4
|
uses: actions/setup-python@v5.2.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||||
- name: Fail job if Python cache restore failed
|
- name: Fail job if Python cache restore failed
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -247,9 +232,9 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
- name: Restore pre-commit environment from cache
|
- name: Restore pre-commit environment from cache
|
||||||
id: cache-precommit
|
id: cache-precommit
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PRE_COMMIT_HOME }}
|
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
- name: Fail job if cache restore failed
|
- name: Fail job if cache restore failed
|
||||||
@@ -271,19 +256,19 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@v2.1.4
|
uses: actions/setup-python@v5.2.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||||
- name: Fail job if Python cache restore failed
|
- name: Fail job if Python cache restore failed
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -297,69 +282,29 @@ jobs:
|
|||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
pylint supervisor tests
|
pylint supervisor tests
|
||||||
|
|
||||||
lint-pyupgrade:
|
|
||||||
name: Check pyupgrade
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: prepare
|
|
||||||
steps:
|
|
||||||
- name: Check out code from GitHub
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
|
||||||
uses: actions/setup-python@v2.1.4
|
|
||||||
id: python
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
|
||||||
- name: Restore Python virtual environment
|
|
||||||
id: cache-venv
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: venv
|
|
||||||
key: |
|
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
|
||||||
- name: Fail job if Python cache restore failed
|
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
echo "Failed to restore Python virtual environment from cache"
|
|
||||||
exit 1
|
|
||||||
- name: Restore pre-commit environment from cache
|
|
||||||
id: cache-precommit
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: ${{ env.PRE_COMMIT_HOME }}
|
|
||||||
key: |
|
|
||||||
${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
|
||||||
- name: Fail job if cache restore failed
|
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
echo "Failed to restore Python virtual environment from cache"
|
|
||||||
exit 1
|
|
||||||
- name: Run pyupgrade
|
|
||||||
run: |
|
|
||||||
. venv/bin/activate
|
|
||||||
pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure
|
|
||||||
|
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: prepare
|
needs: prepare
|
||||||
strategy:
|
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
|
||||||
matrix:
|
|
||||||
python-version: [3.8]
|
|
||||||
name: Run tests Python ${{ matrix.python-version }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@v2.1.4
|
uses: actions/setup-python@v5.2.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
|
- name: Install Cosign
|
||||||
|
uses: sigstore/cosign-installer@v3.7.0
|
||||||
|
with:
|
||||||
|
cosign-release: "v2.4.0"
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||||
- name: Fail job if Python cache restore failed
|
- name: Fail job if Python cache restore failed
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -368,7 +313,7 @@ jobs:
|
|||||||
- name: Install additional system dependencies
|
- name: Install additional system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y --no-install-recommends libpulse0 libudev1
|
sudo apt-get install -y --no-install-recommends libpulse0 libudev1 dbus-daemon
|
||||||
- name: Register Python problem matcher
|
- name: Register Python problem matcher
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/matchers/python.json"
|
echo "::add-matcher::.github/workflows/matchers/python.json"
|
||||||
@@ -390,37 +335,38 @@ jobs:
|
|||||||
-o console_output_style=count \
|
-o console_output_style=count \
|
||||||
tests
|
tests
|
||||||
- name: Upload coverage artifact
|
- name: Upload coverage artifact
|
||||||
uses: actions/upload-artifact@v2.2.1
|
uses: actions/upload-artifact@v4.4.3
|
||||||
with:
|
with:
|
||||||
name: coverage-${{ matrix.python-version }}
|
name: coverage-${{ matrix.python-version }}
|
||||||
path: .coverage
|
path: .coverage
|
||||||
|
include-hidden-files: true
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Process test coverage
|
name: Process test coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: pytest
|
needs: ["pytest", "prepare"]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||||
uses: actions/setup-python@v2.1.4
|
uses: actions/setup-python@v5.2.0
|
||||||
id: python
|
id: python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v4.1.1
|
||||||
with:
|
with:
|
||||||
path: venv
|
path: venv
|
||||||
key: |
|
key: |
|
||||||
${{ runner.os }}-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
|
||||||
- name: Fail job if Python cache restore failed
|
- name: Fail job if Python cache restore failed
|
||||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Failed to restore Python virtual environment from cache"
|
echo "Failed to restore Python virtual environment from cache"
|
||||||
exit 1
|
exit 1
|
||||||
- name: Download all coverage artifacts
|
- name: Download all coverage artifacts
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v4.1.8
|
||||||
- name: Combine coverage results
|
- name: Combine coverage results
|
||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
@@ -428,4 +374,4 @@ jobs:
|
|||||||
coverage report
|
coverage report
|
||||||
coverage xml
|
coverage xml
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v1.0.15
|
uses: codecov/codecov-action@v4.6.0
|
||||||
|
|||||||
10
.github/workflows/lock.yml
vendored
10
.github/workflows/lock.yml
vendored
@@ -9,12 +9,12 @@ jobs:
|
|||||||
lock:
|
lock:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v2.0.1
|
- uses: dessant/lock-threads@v5.0.1
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
issue-lock-inactive-days: "30"
|
issue-inactive-days: "30"
|
||||||
issue-exclude-created-before: "2020-10-01T00:00:00Z"
|
exclude-issue-created-before: "2020-10-01T00:00:00Z"
|
||||||
issue-lock-reason: ""
|
issue-lock-reason: ""
|
||||||
pr-lock-inactive-days: "1"
|
pr-inactive-days: "1"
|
||||||
pr-exclude-created-before: "2020-11-01T00:00:00Z"
|
exclude-pr-created-before: "2020-11-01T00:00:00Z"
|
||||||
pr-lock-reason: ""
|
pr-lock-reason: ""
|
||||||
|
|||||||
30
.github/workflows/matchers/flake8.json
vendored
30
.github/workflows/matchers/flake8.json
vendored
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"problemMatcher": [
|
|
||||||
{
|
|
||||||
"owner": "flake8-error",
|
|
||||||
"severity": "error",
|
|
||||||
"pattern": [
|
|
||||||
{
|
|
||||||
"regexp": "^(.*):(\\d+):(\\d+):\\s(E\\d{3}\\s.*)$",
|
|
||||||
"file": 1,
|
|
||||||
"line": 2,
|
|
||||||
"column": 3,
|
|
||||||
"message": 4
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"owner": "flake8-warning",
|
|
||||||
"severity": "warning",
|
|
||||||
"pattern": [
|
|
||||||
{
|
|
||||||
"regexp": "^(.*):(\\d+):(\\d+):\\s([CDFNW]\\d{3}\\s.*)$",
|
|
||||||
"file": 1,
|
|
||||||
"line": 2,
|
|
||||||
"column": 3,
|
|
||||||
"message": 4
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
33
.github/workflows/release-drafter.yml
vendored
33
.github/workflows/release-drafter.yml
vendored
@@ -2,14 +2,43 @@ name: Release Drafter
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# branches to consider in the event; optional, defaults to all
|
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update_release_draft:
|
update_release_draft:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
name: Release Drafter
|
||||||
steps:
|
steps:
|
||||||
- uses: release-drafter/release-drafter@v5
|
- name: Checkout the repository
|
||||||
|
uses: actions/checkout@v4.2.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Find Next Version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
declare -i newpost
|
||||||
|
latest=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||||
|
latestpre=$(echo "$latest" | awk '{split($0,a,"."); print a[1] "." a[2]}')
|
||||||
|
datepre=$(date --utc '+%Y.%m')
|
||||||
|
|
||||||
|
|
||||||
|
if [[ "$latestpre" == "$datepre" ]]; then
|
||||||
|
latestpost=$(echo "$latest" | awk '{split($0,a,"."); print a[3]}')
|
||||||
|
newpost=$latestpost+1
|
||||||
|
else
|
||||||
|
newpost=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo Current version: $latest
|
||||||
|
echo New target version: $datepre.$newpost
|
||||||
|
echo "version=$datepre.$newpost" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Run Release Drafter
|
||||||
|
uses: release-drafter/release-drafter@v6.0.0
|
||||||
|
with:
|
||||||
|
tag: ${{ steps.version.outputs.version }}
|
||||||
|
name: ${{ steps.version.outputs.version }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
4
.github/workflows/sentry.yaml
vendored
4
.github/workflows/sentry.yaml
vendored
@@ -10,9 +10,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.2.1
|
||||||
- name: Sentry Release
|
- name: Sentry Release
|
||||||
uses: getsentry/action-release@v1.1
|
uses: getsentry/action-release@v1.7.0
|
||||||
env:
|
env:
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
|||||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v3.0.14
|
- uses: actions/stale@v9.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
days-before-stale: 60
|
days-before-stale: 30
|
||||||
days-before-close: 7
|
days-before-close: 7
|
||||||
stale-issue-label: "stale"
|
stale-issue-label: "stale"
|
||||||
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"
|
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
ignored:
|
ignored:
|
||||||
- DL3018
|
- DL3003
|
||||||
- DL3006
|
- DL3006
|
||||||
- DL3013
|
- DL3013
|
||||||
|
- DL3018
|
||||||
|
- DL3042
|
||||||
- SC2155
|
- SC2155
|
||||||
|
|||||||
@@ -1,34 +1,15 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 20.8b1
|
rev: v0.5.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: ruff
|
||||||
args:
|
args:
|
||||||
- --safe
|
- --fix
|
||||||
- --quiet
|
- id: ruff-format
|
||||||
- --target-version
|
|
||||||
- py38
|
|
||||||
files: ^((supervisor|tests)/.+)?[^/]+\.py$
|
files: ^((supervisor|tests)/.+)?[^/]+\.py$
|
||||||
- repo: https://gitlab.com/pycqa/flake8
|
|
||||||
rev: 3.8.3
|
|
||||||
hooks:
|
|
||||||
- id: flake8
|
|
||||||
additional_dependencies:
|
|
||||||
- flake8-docstrings==1.5.0
|
|
||||||
- pydocstyle==5.0.2
|
|
||||||
files: ^(supervisor|script|tests)/.+\.py$
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v3.1.0
|
rev: v4.5.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-executables-have-shebangs
|
- id: check-executables-have-shebangs
|
||||||
stages: [manual]
|
stages: [manual]
|
||||||
- id: check-json
|
- id: check-json
|
||||||
- repo: https://github.com/pre-commit/mirrors-isort
|
|
||||||
rev: v4.3.21
|
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
|
||||||
rev: v2.6.2
|
|
||||||
hooks:
|
|
||||||
- id: pyupgrade
|
|
||||||
args: [--py37-plus]
|
|
||||||
|
|||||||
21
.vcnignore
Normal file
21
.vcnignore
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# General files
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.devcontainer
|
||||||
|
.vscode
|
||||||
|
.tox
|
||||||
|
|
||||||
|
# Data
|
||||||
|
home-assistant-polymer/
|
||||||
|
script/
|
||||||
|
tests/
|
||||||
|
data/
|
||||||
|
venv/
|
||||||
7
.vscode/launch.json
vendored
7
.vscode/launch.json
vendored
@@ -13,6 +13,13 @@
|
|||||||
"remoteRoot": "/usr/src/supervisor"
|
"remoteRoot": "/usr/src/supervisor"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Tests",
|
||||||
|
"type": "python",
|
||||||
|
"request": "test",
|
||||||
|
"console": "internalConsole",
|
||||||
|
"justMyCode": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
35
.vscode/tasks.json
vendored
35
.vscode/tasks.json
vendored
@@ -2,9 +2,9 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "Run Testenv",
|
"label": "Run Supervisor",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "./scripts/test_env.sh",
|
"command": "supervisor_run",
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "test",
|
"kind": "test",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Run Testenv CLI",
|
"label": "Run Supervisor CLI",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "docker exec -ti hassio_cli /usr/bin/cli.sh",
|
"command": "docker exec -ti hassio_cli /usr/bin/cli.sh",
|
||||||
"group": {
|
"group": {
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Update UI",
|
"label": "Update Supervisor Panel",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "./scripts/update-frontend.sh",
|
"command": "LOKALISE_TOKEN='${input:localiseToken}' ./scripts/update-frontend.sh",
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "build",
|
"kind": "build",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
@@ -58,9 +58,23 @@
|
|||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Flake8",
|
"label": "Ruff Check",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "flake8 supervisor tests",
|
"command": "ruff check --fix supervisor tests",
|
||||||
|
"group": {
|
||||||
|
"kind": "test",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "new"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Ruff Format",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "ruff format supervisor tests",
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "test",
|
"kind": "test",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
@@ -86,5 +100,12 @@
|
|||||||
},
|
},
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"id": "localiseToken",
|
||||||
|
"type": "promptString",
|
||||||
|
"description": "Paste your lokalise token to download frontend translations"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
37
Dockerfile
37
Dockerfile
@@ -1,38 +1,49 @@
|
|||||||
ARG BUILD_FROM
|
ARG BUILD_FROM
|
||||||
FROM $BUILD_FROM
|
FROM ${BUILD_FROM}
|
||||||
|
|
||||||
ENV \
|
ENV \
|
||||||
S6_SERVICES_GRACETIME=10000 \
|
S6_SERVICES_GRACETIME=10000 \
|
||||||
SUPERVISOR_API=http://localhost
|
SUPERVISOR_API=http://localhost \
|
||||||
|
CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1 \
|
||||||
|
UV_SYSTEM_PYTHON=true
|
||||||
|
|
||||||
|
ARG \
|
||||||
|
COSIGN_VERSION \
|
||||||
|
BUILD_ARCH
|
||||||
|
|
||||||
# Install base
|
# Install base
|
||||||
|
WORKDIR /usr/src
|
||||||
RUN \
|
RUN \
|
||||||
apk add --no-cache \
|
set -x \
|
||||||
|
&& apk add --no-cache \
|
||||||
|
findutils \
|
||||||
eudev \
|
eudev \
|
||||||
eudev-libs \
|
eudev-libs \
|
||||||
git \
|
git \
|
||||||
glib \
|
|
||||||
libffi \
|
libffi \
|
||||||
libpulse \
|
libpulse \
|
||||||
musl \
|
musl \
|
||||||
openssl
|
openssl \
|
||||||
|
yaml \
|
||||||
ARG BUILD_ARCH
|
\
|
||||||
WORKDIR /usr/src
|
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
|
||||||
|
&& chmod a+x /usr/bin/cosign \
|
||||||
|
&& pip3 install uv==0.2.21
|
||||||
|
|
||||||
# Install requirements
|
# Install requirements
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN \
|
RUN \
|
||||||
export MAKEFLAGS="-j$(nproc)" \
|
if [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||||
&& pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links \
|
linux32 uv pip install --no-build -r requirements.txt; \
|
||||||
"https://wheels.home-assistant.io/alpine-$(cut -d '.' -f 1-2 < /etc/alpine-release)/${BUILD_ARCH}/" \
|
else \
|
||||||
-r ./requirements.txt \
|
uv pip install --no-build -r requirements.txt; \
|
||||||
|
fi \
|
||||||
&& rm -f requirements.txt
|
&& rm -f requirements.txt
|
||||||
|
|
||||||
# Install Home Assistant Supervisor
|
# Install Home Assistant Supervisor
|
||||||
COPY . supervisor
|
COPY . supervisor
|
||||||
RUN \
|
RUN \
|
||||||
pip3 install --no-cache-dir -e ./supervisor \
|
pip3 install -e ./supervisor \
|
||||||
&& python3 -m compileall ./supervisor/supervisor
|
&& python3 -m compileall ./supervisor/supervisor
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -10,26 +10,25 @@ network settings or installing and updating software.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Installation instructions can be found at https://home-assistant.io/hassio.
|
Installation instructions can be found at https://home-assistant.io/getting-started.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The development of the Supervisor is not difficult but tricky.
|
For small changes and bugfixes you can just follow this, but for significant changes open a RFC first.
|
||||||
|
Development instructions can be found [here][development].
|
||||||
- You can use the builder to create your Supervisor: https://github.com/home-assistant/hassio-builder
|
|
||||||
- Access a HassOS device or VM and pull your Supervisor.
|
|
||||||
- Set the developer modus with the CLI tool: `ha supervisor options --channel=dev`
|
|
||||||
- Tag it as `homeassistant/xy-hassio-supervisor:latest`
|
|
||||||
- Restart the service with `systemctl restart hassos-supervisor | journalctl -fu hassos-supervisor`
|
|
||||||
- Test your changes
|
|
||||||
|
|
||||||
For small bugfixes or improvements, make a PR. For significant changes open a RFC first, please. Thanks.
|
|
||||||
|
|
||||||
## Release
|
## Release
|
||||||
|
|
||||||
Follow is the relase circle process:
|
Releases are done in 3 stages (channels) with this structure:
|
||||||
|
|
||||||
1. Merge master into dev / make sure version stay on dev
|
1. Pull requests are merged to the `main` branch.
|
||||||
2. Merge dev into master
|
2. A new build is pushed to the `dev` stage.
|
||||||
3. Bump the release on master
|
3. Releases are published.
|
||||||
4. Create a GitHub Release from master with the right version tag
|
4. A new build is pushed to the `beta` stage.
|
||||||
|
5. The [`stable.json`][stable] file is updated.
|
||||||
|
6. The build that was pushed to `beta` will now be pushed to `stable`.
|
||||||
|
|
||||||
|
[development]: https://developers.home-assistant.io/docs/supervisor/development
|
||||||
|
[stable]: https://github.com/home-assistant/version/blob/master/stable.json
|
||||||
|
|
||||||
|
[](https://www.openhomefoundation.org/)
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# https://dev.azure.com/home-assistant
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
batch: true
|
|
||||||
branches:
|
|
||||||
include:
|
|
||||||
- main
|
|
||||||
pr: none
|
|
||||||
variables:
|
|
||||||
- name: versionWheels
|
|
||||||
value: "1.13.0-3.8-alpine3.12"
|
|
||||||
resources:
|
|
||||||
repositories:
|
|
||||||
- repository: azure
|
|
||||||
type: github
|
|
||||||
name: "home-assistant/ci-azure"
|
|
||||||
endpoint: "home-assistant"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
- template: templates/azp-job-wheels.yaml@azure
|
|
||||||
parameters:
|
|
||||||
builderVersion: "$(versionWheels)"
|
|
||||||
builderApk: "build-base;libffi-dev;openssl-dev"
|
|
||||||
builderPip: "Cython"
|
|
||||||
skipBinary: "aiohttp"
|
|
||||||
wheelsRequirement: "requirements.txt"
|
|
||||||
13
build.json
13
build.json
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"image": "homeassistant/{arch}-hassio-supervisor",
|
|
||||||
"build_from": {
|
|
||||||
"aarch64": "homeassistant/aarch64-base-python:3.8-alpine3.12",
|
|
||||||
"armhf": "homeassistant/armhf-base-python:3.8-alpine3.12",
|
|
||||||
"armv7": "homeassistant/armv7-base-python:3.8-alpine3.12",
|
|
||||||
"amd64": "homeassistant/amd64-base-python:3.8-alpine3.12",
|
|
||||||
"i386": "homeassistant/i386-base-python:3.8-alpine3.12"
|
|
||||||
},
|
|
||||||
"labels": {
|
|
||||||
"io.hass.type": "supervisor"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
build.yaml
Normal file
24
build.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
image: ghcr.io/home-assistant/{arch}-hassio-supervisor
|
||||||
|
build_from:
|
||||||
|
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.12-alpine3.20
|
||||||
|
armhf: ghcr.io/home-assistant/armhf-base-python:3.12-alpine3.20
|
||||||
|
armv7: ghcr.io/home-assistant/armv7-base-python:3.12-alpine3.20
|
||||||
|
amd64: ghcr.io/home-assistant/amd64-base-python:3.12-alpine3.20
|
||||||
|
i386: ghcr.io/home-assistant/i386-base-python:3.12-alpine3.20
|
||||||
|
codenotary:
|
||||||
|
signer: notary@home-assistant.io
|
||||||
|
base_image: notary@home-assistant.io
|
||||||
|
cosign:
|
||||||
|
base_identity: https://github.com/home-assistant/docker-base/.*
|
||||||
|
identity: https://github.com/home-assistant/supervisor/.*
|
||||||
|
args:
|
||||||
|
COSIGN_VERSION: 2.4.0
|
||||||
|
labels:
|
||||||
|
io.hass.type: supervisor
|
||||||
|
org.opencontainers.image.title: Home Assistant Supervisor
|
||||||
|
org.opencontainers.image.description: Container-based system for managing Home Assistant Core installation
|
||||||
|
org.opencontainers.image.source: https://github.com/home-assistant/supervisor
|
||||||
|
org.opencontainers.image.authors: The Home Assistant Authors
|
||||||
|
org.opencontainers.image.url: https://www.home-assistant.io/
|
||||||
|
org.opencontainers.image.documentation: https://www.home-assistant.io/docs/
|
||||||
|
org.opencontainers.image.licenses: Apache License 2.0
|
||||||
Submodule home-assistant-polymer updated: 1d367eca69...9d457d52e8
46
pylintrc
46
pylintrc
@@ -1,46 +0,0 @@
|
|||||||
[MASTER]
|
|
||||||
reports=no
|
|
||||||
jobs=2
|
|
||||||
|
|
||||||
good-names=id,i,j,k,ex,Run,_,fp,T
|
|
||||||
|
|
||||||
# Reasons disabled:
|
|
||||||
# format - handled by black
|
|
||||||
# locally-disabled - it spams too much
|
|
||||||
# duplicate-code - unavoidable
|
|
||||||
# cyclic-import - doesn't test if both import on load
|
|
||||||
# abstract-class-little-used - prevents from setting right foundation
|
|
||||||
# abstract-class-not-used - is flaky, should not show up but does
|
|
||||||
# unused-argument - generic callbacks and setup methods create a lot of warnings
|
|
||||||
# redefined-variable-type - this is Python, we're duck typing!
|
|
||||||
# too-many-* - are not enforced for the sake of readability
|
|
||||||
# too-few-* - same as too-many-*
|
|
||||||
# abstract-method - with intro of async there are always methods missing
|
|
||||||
disable=
|
|
||||||
format,
|
|
||||||
abstract-class-little-used,
|
|
||||||
abstract-method,
|
|
||||||
cyclic-import,
|
|
||||||
duplicate-code,
|
|
||||||
locally-disabled,
|
|
||||||
no-else-return,
|
|
||||||
no-self-use,
|
|
||||||
not-context-manager,
|
|
||||||
redefined-variable-type,
|
|
||||||
too-few-public-methods,
|
|
||||||
too-many-arguments,
|
|
||||||
too-many-branches,
|
|
||||||
too-many-instance-attributes,
|
|
||||||
too-many-lines,
|
|
||||||
too-many-locals,
|
|
||||||
too-many-public-methods,
|
|
||||||
too-many-return-statements,
|
|
||||||
too-many-statements,
|
|
||||||
unused-argument,
|
|
||||||
|
|
||||||
[EXCEPTIONS]
|
|
||||||
overgeneral-exceptions=Exception
|
|
||||||
|
|
||||||
|
|
||||||
[TYPECHECK]
|
|
||||||
ignored-modules = distutils
|
|
||||||
373
pyproject.toml
Normal file
373
pyproject.toml
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools~=68.0.0", "wheel~=0.40.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "Supervisor"
|
||||||
|
dynamic = ["version", "dependencies"]
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
description = "Open-source private cloud os for Home-Assistant based on HassOS"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "The Home Assistant Authors", email = "hello@home-assistant.io" },
|
||||||
|
]
|
||||||
|
keywords = ["docker", "home-assistant", "api"]
|
||||||
|
requires-python = ">=3.12.0"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://www.home-assistant.io/"
|
||||||
|
"Source Code" = "https://github.com/home-assistant/supervisor"
|
||||||
|
"Bug Reports" = "https://github.com/home-assistant/supervisor/issues"
|
||||||
|
"Docs: Dev" = "https://developers.home-assistant.io/"
|
||||||
|
"Discord" = "https://www.home-assistant.io/join-chat/"
|
||||||
|
"Forum" = "https://community.home-assistant.io/"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
platforms = ["any"]
|
||||||
|
zip-safe = false
|
||||||
|
include-package-data = true
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["supervisor*"]
|
||||||
|
|
||||||
|
[tool.pylint.MAIN]
|
||||||
|
py-version = "3.12"
|
||||||
|
# Use a conservative default here; 2 should speed up most setups and not hurt
|
||||||
|
# any too bad. Override on command line as appropriate.
|
||||||
|
jobs = 2
|
||||||
|
persistent = false
|
||||||
|
extension-pkg-allow-list = ["ciso8601"]
|
||||||
|
|
||||||
|
[tool.pylint.BASIC]
|
||||||
|
class-const-naming-style = "any"
|
||||||
|
good-names = ["id", "i", "j", "k", "ex", "Run", "_", "fp", "T", "os"]
|
||||||
|
|
||||||
|
[tool.pylint."MESSAGES CONTROL"]
|
||||||
|
# Reasons disabled:
|
||||||
|
# format - handled by ruff
|
||||||
|
# abstract-method - with intro of async there are always methods missing
|
||||||
|
# cyclic-import - doesn't test if both import on load
|
||||||
|
# duplicate-code - unavoidable
|
||||||
|
# locally-disabled - it spams too much
|
||||||
|
# too-many-* - are not enforced for the sake of readability
|
||||||
|
# too-few-* - same as too-many-*
|
||||||
|
# unused-argument - generic callbacks and setup methods create a lot of warnings
|
||||||
|
disable = [
|
||||||
|
"format",
|
||||||
|
"abstract-method",
|
||||||
|
"cyclic-import",
|
||||||
|
"duplicate-code",
|
||||||
|
"locally-disabled",
|
||||||
|
"no-else-return",
|
||||||
|
"not-context-manager",
|
||||||
|
"too-few-public-methods",
|
||||||
|
"too-many-arguments",
|
||||||
|
"too-many-branches",
|
||||||
|
"too-many-instance-attributes",
|
||||||
|
"too-many-lines",
|
||||||
|
"too-many-locals",
|
||||||
|
"too-many-public-methods",
|
||||||
|
"too-many-return-statements",
|
||||||
|
"too-many-statements",
|
||||||
|
"unused-argument",
|
||||||
|
"consider-using-with",
|
||||||
|
|
||||||
|
# Handled by ruff
|
||||||
|
# Ref: <https://github.com/astral-sh/ruff/issues/970>
|
||||||
|
"await-outside-async", # PLE1142
|
||||||
|
"bad-str-strip-call", # PLE1310
|
||||||
|
"bad-string-format-type", # PLE1307
|
||||||
|
"bidirectional-unicode", # PLE2502
|
||||||
|
"continue-in-finally", # PLE0116
|
||||||
|
"duplicate-bases", # PLE0241
|
||||||
|
"format-needs-mapping", # F502
|
||||||
|
"function-redefined", # F811
|
||||||
|
# Needed because ruff does not understand type of __all__ generated by a function
|
||||||
|
# "invalid-all-format", # PLE0605
|
||||||
|
"invalid-all-object", # PLE0604
|
||||||
|
"invalid-character-backspace", # PLE2510
|
||||||
|
"invalid-character-esc", # PLE2513
|
||||||
|
"invalid-character-nul", # PLE2514
|
||||||
|
"invalid-character-sub", # PLE2512
|
||||||
|
"invalid-character-zero-width-space", # PLE2515
|
||||||
|
"logging-too-few-args", # PLE1206
|
||||||
|
"logging-too-many-args", # PLE1205
|
||||||
|
"missing-format-string-key", # F524
|
||||||
|
"mixed-format-string", # F506
|
||||||
|
"no-method-argument", # N805
|
||||||
|
"no-self-argument", # N805
|
||||||
|
"nonexistent-operator", # B002
|
||||||
|
"nonlocal-without-binding", # PLE0117
|
||||||
|
"not-in-loop", # F701, F702
|
||||||
|
"notimplemented-raised", # F901
|
||||||
|
"return-in-init", # PLE0101
|
||||||
|
"return-outside-function", # F706
|
||||||
|
"syntax-error", # E999
|
||||||
|
"too-few-format-args", # F524
|
||||||
|
"too-many-format-args", # F522
|
||||||
|
"too-many-star-expressions", # F622
|
||||||
|
"truncated-format-string", # F501
|
||||||
|
"undefined-all-variable", # F822
|
||||||
|
"undefined-variable", # F821
|
||||||
|
"used-prior-global-declaration", # PLE0118
|
||||||
|
"yield-inside-async-function", # PLE1700
|
||||||
|
"yield-outside-function", # F704
|
||||||
|
"anomalous-backslash-in-string", # W605
|
||||||
|
"assert-on-string-literal", # PLW0129
|
||||||
|
"assert-on-tuple", # F631
|
||||||
|
"bad-format-string", # W1302, F
|
||||||
|
"bad-format-string-key", # W1300, F
|
||||||
|
"bare-except", # E722
|
||||||
|
"binary-op-exception", # PLW0711
|
||||||
|
"cell-var-from-loop", # B023
|
||||||
|
# "dangerous-default-value", # B006, ruff catches new occurrences, needs more work
|
||||||
|
"duplicate-except", # B014
|
||||||
|
"duplicate-key", # F601
|
||||||
|
"duplicate-string-formatting-argument", # F
|
||||||
|
"duplicate-value", # F
|
||||||
|
"eval-used", # PGH001
|
||||||
|
"exec-used", # S102
|
||||||
|
# "expression-not-assigned", # B018, ruff catches new occurrences, needs more work
|
||||||
|
"f-string-without-interpolation", # F541
|
||||||
|
"forgotten-debug-statement", # T100
|
||||||
|
"format-string-without-interpolation", # F
|
||||||
|
# "global-statement", # PLW0603, ruff catches new occurrences, needs more work
|
||||||
|
"global-variable-not-assigned", # PLW0602
|
||||||
|
"implicit-str-concat", # ISC001
|
||||||
|
"import-self", # PLW0406
|
||||||
|
"inconsistent-quotes", # Q000
|
||||||
|
"invalid-envvar-default", # PLW1508
|
||||||
|
"keyword-arg-before-vararg", # B026
|
||||||
|
"logging-format-interpolation", # G
|
||||||
|
"logging-fstring-interpolation", # G
|
||||||
|
"logging-not-lazy", # G
|
||||||
|
"misplaced-future", # F404
|
||||||
|
"named-expr-without-context", # PLW0131
|
||||||
|
"nested-min-max", # PLW3301
|
||||||
|
# "pointless-statement", # B018, ruff catches new occurrences, needs more work
|
||||||
|
"raise-missing-from", # TRY200
|
||||||
|
# "redefined-builtin", # A001, ruff is way more stricter, needs work
|
||||||
|
"try-except-raise", # TRY302
|
||||||
|
"unused-argument", # ARG001, we don't use it
|
||||||
|
"unused-format-string-argument", #F507
|
||||||
|
"unused-format-string-key", # F504
|
||||||
|
"unused-import", # F401
|
||||||
|
"unused-variable", # F841
|
||||||
|
"useless-else-on-loop", # PLW0120
|
||||||
|
"wildcard-import", # F403
|
||||||
|
"bad-classmethod-argument", # N804
|
||||||
|
"consider-iterating-dictionary", # SIM118
|
||||||
|
"empty-docstring", # D419
|
||||||
|
"invalid-name", # N815
|
||||||
|
"line-too-long", # E501, disabled globally
|
||||||
|
"missing-class-docstring", # D101
|
||||||
|
"missing-final-newline", # W292
|
||||||
|
"missing-function-docstring", # D103
|
||||||
|
"missing-module-docstring", # D100
|
||||||
|
"multiple-imports", #E401
|
||||||
|
"singleton-comparison", # E711, E712
|
||||||
|
"subprocess-run-check", # PLW1510
|
||||||
|
"superfluous-parens", # UP034
|
||||||
|
"ungrouped-imports", # I001
|
||||||
|
"unidiomatic-typecheck", # E721
|
||||||
|
"unnecessary-direct-lambda-call", # PLC3002
|
||||||
|
"unnecessary-lambda-assignment", # PLC3001
|
||||||
|
"unneeded-not", # SIM208
|
||||||
|
"useless-import-alias", # PLC0414
|
||||||
|
"wrong-import-order", # I001
|
||||||
|
"wrong-import-position", # E402
|
||||||
|
"comparison-of-constants", # PLR0133
|
||||||
|
"comparison-with-itself", # PLR0124
|
||||||
|
# "consider-alternative-union-syntax", # UP007, typing extension
|
||||||
|
"consider-merging-isinstance", # PLR1701
|
||||||
|
# "consider-using-alias", # UP006, typing extension
|
||||||
|
"consider-using-dict-comprehension", # C402
|
||||||
|
"consider-using-generator", # C417
|
||||||
|
"consider-using-get", # SIM401
|
||||||
|
"consider-using-set-comprehension", # C401
|
||||||
|
"consider-using-sys-exit", # PLR1722
|
||||||
|
"consider-using-ternary", # SIM108
|
||||||
|
"literal-comparison", # F632
|
||||||
|
"property-with-parameters", # PLR0206
|
||||||
|
"super-with-arguments", # UP008
|
||||||
|
"too-many-branches", # PLR0912
|
||||||
|
"too-many-return-statements", # PLR0911
|
||||||
|
"too-many-statements", # PLR0915
|
||||||
|
"trailing-comma-tuple", # COM818
|
||||||
|
"unnecessary-comprehension", # C416
|
||||||
|
"use-a-generator", # C417
|
||||||
|
"use-dict-literal", # C406
|
||||||
|
"use-list-literal", # C405
|
||||||
|
"useless-object-inheritance", # UP004
|
||||||
|
"useless-return", # PLR1711
|
||||||
|
# "no-self-use", # PLR6301 # Optional plugin, not enabled
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pylint.REPORTS]
|
||||||
|
score = false
|
||||||
|
|
||||||
|
[tool.pylint.TYPECHECK]
|
||||||
|
ignored-modules = ["distutils"]
|
||||||
|
|
||||||
|
[tool.pylint.FORMAT]
|
||||||
|
expected-line-ending-format = "LF"
|
||||||
|
|
||||||
|
[tool.pylint.EXCEPTIONS]
|
||||||
|
overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"]
|
||||||
|
|
||||||
|
[tool.pylint.DESIGN]
|
||||||
|
max-positional-arguments = 10
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
norecursedirs = [".git"]
|
||||||
|
log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s"
|
||||||
|
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
filterwarnings = [
|
||||||
|
"error",
|
||||||
|
"ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash",
|
||||||
|
"ignore::pytest.PytestUnraisableExceptionWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
lint.select = [
|
||||||
|
"B002", # Python does not support the unary prefix increment
|
||||||
|
"B007", # Loop control variable {name} not used within loop body
|
||||||
|
"B014", # Exception handler with duplicate exception
|
||||||
|
"B023", # Function definition does not bind loop variable {name}
|
||||||
|
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
|
||||||
|
"B904", # Use raise from to specify exception cause
|
||||||
|
"C", # complexity
|
||||||
|
"COM818", # Trailing comma on bare tuple prohibited
|
||||||
|
"D", # docstrings
|
||||||
|
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
|
||||||
|
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
|
||||||
|
"E", # pycodestyle
|
||||||
|
"F", # pyflakes/autoflake
|
||||||
|
"G", # flake8-logging-format
|
||||||
|
"I", # isort
|
||||||
|
"ICN001", # import concentions; {name} should be imported as {asname}
|
||||||
|
"N804", # First argument of a class method should be named cls
|
||||||
|
"N805", # First argument of a method should be named self
|
||||||
|
"N815", # Variable {name} in class scope should not be mixedCase
|
||||||
|
"PGH004", # Use specific rule codes when using noqa
|
||||||
|
"PLC0414", # Useless import alias. Import alias does not rename original package.
|
||||||
|
"PLC", # pylint
|
||||||
|
"PLE", # pylint
|
||||||
|
"PLR", # pylint
|
||||||
|
"PLW", # pylint
|
||||||
|
"Q000", # Double quotes found but single quotes preferred
|
||||||
|
"RUF006", # Store a reference to the return value of asyncio.create_task
|
||||||
|
"S102", # Use of exec detected
|
||||||
|
"S103", # bad-file-permissions
|
||||||
|
"S108", # hardcoded-temp-file
|
||||||
|
"S306", # suspicious-mktemp-usage
|
||||||
|
"S307", # suspicious-eval-usage
|
||||||
|
"S313", # suspicious-xmlc-element-tree-usage
|
||||||
|
"S314", # suspicious-xml-element-tree-usage
|
||||||
|
"S315", # suspicious-xml-expat-reader-usage
|
||||||
|
"S316", # suspicious-xml-expat-builder-usage
|
||||||
|
"S317", # suspicious-xml-sax-usage
|
||||||
|
"S318", # suspicious-xml-mini-dom-usage
|
||||||
|
"S319", # suspicious-xml-pull-dom-usage
|
||||||
|
"S320", # suspicious-xmle-tree-usage
|
||||||
|
"S601", # paramiko-call
|
||||||
|
"S602", # subprocess-popen-with-shell-equals-true
|
||||||
|
"S604", # call-with-shell-equals-true
|
||||||
|
"S608", # hardcoded-sql-expression
|
||||||
|
"S609", # unix-command-wildcard-injection
|
||||||
|
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
|
||||||
|
"SIM117", # Merge with-statements that use the same scope
|
||||||
|
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
|
||||||
|
"SIM201", # Use {left} != {right} instead of not {left} == {right}
|
||||||
|
"SIM208", # Use {expr} instead of not (not {expr})
|
||||||
|
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
|
||||||
|
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
|
||||||
|
"SIM401", # Use get from dict with default instead of an if block
|
||||||
|
"T100", # Trace found: {name} used
|
||||||
|
"T20", # flake8-print
|
||||||
|
"TID251", # Banned imports
|
||||||
|
"TRY004", # Prefer TypeError exception for invalid type
|
||||||
|
"TRY302", # Remove exception handler; error is immediately re-raised
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"W", # pycodestyle
|
||||||
|
]
|
||||||
|
|
||||||
|
lint.ignore = [
|
||||||
|
"D202", # No blank lines allowed after function docstring
|
||||||
|
"D203", # 1 blank line required before class docstring
|
||||||
|
"D213", # Multi-line docstring summary should start at the second line
|
||||||
|
"D406", # Section name should end with a newline
|
||||||
|
"D407", # Section name underlining
|
||||||
|
"E501", # line too long
|
||||||
|
"E731", # do not assign a lambda expression, use a def
|
||||||
|
|
||||||
|
# Ignore ignored, as the rule is now back in preview/nursery, which cannot
|
||||||
|
# be ignored anymore without warnings.
|
||||||
|
# https://github.com/astral-sh/ruff/issues/7491
|
||||||
|
# "PLC1901", # Lots of false positives
|
||||||
|
|
||||||
|
# False positives https://github.com/astral-sh/ruff/issues/5386
|
||||||
|
"PLC0208", # Use a sequence type instead of a `set` when iterating over values
|
||||||
|
"PLR0911", # Too many return statements ({returns} > {max_returns})
|
||||||
|
"PLR0912", # Too many branches ({branches} > {max_branches})
|
||||||
|
"PLR0913", # Too many arguments to function call ({c_args} > {max_args})
|
||||||
|
"PLR0915", # Too many statements ({statements} > {max_statements})
|
||||||
|
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
|
||||||
|
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
|
||||||
|
"UP006", # keep type annotation style as is
|
||||||
|
"UP007", # keep type annotation style as is
|
||||||
|
# Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923
|
||||||
|
"UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)`
|
||||||
|
|
||||||
|
# May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
|
||||||
|
"W191",
|
||||||
|
"E111",
|
||||||
|
"E114",
|
||||||
|
"E117",
|
||||||
|
"D206",
|
||||||
|
"D300",
|
||||||
|
"Q000",
|
||||||
|
"Q001",
|
||||||
|
"Q002",
|
||||||
|
"Q003",
|
||||||
|
"COM812",
|
||||||
|
"COM819",
|
||||||
|
"ISC001",
|
||||||
|
"ISC002",
|
||||||
|
|
||||||
|
# Disabled because ruff does not understand type of __all__ generated by a function
|
||||||
|
"PLE0605",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
|
||||||
|
voluptuous = "vol"
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-pytest-style]
|
||||||
|
fixture-parentheses = false
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
||||||
|
"pytz".msg = "use zoneinfo instead"
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
force-sort-within-sections = true
|
||||||
|
section-order = [
|
||||||
|
"future",
|
||||||
|
"standard-library",
|
||||||
|
"third-party",
|
||||||
|
"first-party",
|
||||||
|
"local-folder",
|
||||||
|
]
|
||||||
|
forced-separate = ["tests"]
|
||||||
|
known-first-party = ["supervisor", "tests"]
|
||||||
|
combine-as-imports = true
|
||||||
|
split-on-trailing-comma = false
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
|
||||||
|
# DBus Service Mocks must use typing and names understood by dbus-fast
|
||||||
|
"tests/dbus_service_mocks/*.py" = ["F722", "F821", "N815"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.mccabe]
|
||||||
|
max-complexity = 25
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
aiohttp==3.7.3
|
aiodns==3.2.0
|
||||||
async_timeout==3.0.1
|
aiohttp==3.10.10
|
||||||
atomicwrites==1.4.0
|
atomicwrites-homeassistant==1.4.1
|
||||||
attrs==20.3.0
|
attrs==24.2.0
|
||||||
brotli==1.0.9
|
awesomeversion==24.6.0
|
||||||
cchardet==2.1.7
|
brotli==1.1.0
|
||||||
colorlog==4.6.2
|
ciso8601==2.3.1
|
||||||
cpe==1.2.1
|
colorlog==6.8.2
|
||||||
cryptography==3.2.1
|
cpe==1.3.1
|
||||||
debugpy==1.2.0
|
cryptography==43.0.1
|
||||||
docker==4.4.0
|
debugpy==1.8.7
|
||||||
gitpython==3.1.11
|
deepmerge==2.0
|
||||||
jinja2==2.11.2
|
dirhash==0.5.0
|
||||||
packaging==20.4
|
docker==7.1.0
|
||||||
pulsectl==20.5.1
|
faust-cchardet==2.1.19
|
||||||
pytz==2020.4
|
gitpython==3.1.43
|
||||||
pyudev==0.22.0
|
jinja2==3.1.4
|
||||||
ruamel.yaml==0.15.100
|
orjson==3.10.7
|
||||||
sentry-sdk==0.19.4
|
pulsectl==24.8.0
|
||||||
voluptuous==0.12.0
|
pyudev==0.24.3
|
||||||
|
PyYAML==6.0.2
|
||||||
|
requests==2.32.3
|
||||||
|
securetar==2024.2.1
|
||||||
|
sentry-sdk==2.16.0
|
||||||
|
setuptools==75.1.0
|
||||||
|
voluptuous==0.15.2
|
||||||
|
dbus-fast==2.24.3
|
||||||
|
typing_extensions==4.12.2
|
||||||
|
zlib-fast==0.2.0
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
black==20.8b1
|
coverage==7.6.3
|
||||||
codecov==2.1.10
|
pre-commit==4.0.1
|
||||||
coverage==5.3
|
pylint==3.3.1
|
||||||
flake8-docstrings==1.5.0
|
pytest-aiohttp==1.0.5
|
||||||
flake8==3.8.4
|
pytest-asyncio==0.23.6
|
||||||
pre-commit==2.9.2
|
pytest-cov==5.0.0
|
||||||
pydocstyle==5.1.1
|
pytest-timeout==2.3.1
|
||||||
pylint==2.6.0
|
pytest==8.3.3
|
||||||
pytest-aiohttp==0.3.0
|
ruff==0.6.9
|
||||||
pytest-asyncio==0.12.0 # NB!: Versions over 0.12.0 breaks pytest-aiohttp (https://github.com/aio-libs/pytest-aiohttp/issues/16)
|
time-machine==2.16.0
|
||||||
pytest-cov==2.10.1
|
typing_extensions==4.12.2
|
||||||
pytest-timeout==1.4.2
|
urllib3==2.2.3
|
||||||
pytest==6.1.2
|
|
||||||
pyupgrade==2.7.4
|
|
||||||
|
|||||||
8
rootfs/etc/cont-init.d/udev.sh
Normal file → Executable file
8
rootfs/etc/cont-init.d/udev.sh
Normal file → Executable file
@@ -2,9 +2,17 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Start udev service
|
# Start udev service
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
|
if bashio::fs.directory_exists /run/udev && ! bashio::fs.file_exists /run/.old_udev; then
|
||||||
|
bashio::log.info "Using udev information from host"
|
||||||
|
bashio::exit.ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
bashio::log.info "Setup udev backend inside container"
|
||||||
udevd --daemon
|
udevd --daemon
|
||||||
|
|
||||||
bashio::log.info "Update udev information"
|
bashio::log.info "Update udev information"
|
||||||
|
touch /run/.old_udev
|
||||||
if udevadm trigger; then
|
if udevadm trigger; then
|
||||||
udevadm settle || true
|
udevadm settle || true
|
||||||
else
|
else
|
||||||
|
|||||||
11
rootfs/etc/services.d/supervisor/finish
Normal file → Executable file
11
rootfs/etc/services.d/supervisor/finish
Normal file → Executable file
@@ -1,8 +1,11 @@
|
|||||||
#!/usr/bin/execlineb -S1
|
#!/usr/bin/env bashio
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Take down the S6 supervision tree when Supervisor fails
|
# Take down the S6 supervision tree when Supervisor fails
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
if { s6-test ${1} -ne 100 }
|
|
||||||
if { s6-test ${1} -ne 256 }
|
|
||||||
|
|
||||||
redirfd -w 2 /dev/null s6-svscanctl -t /var/run/s6/services
|
if [[ "$1" -ne 100 ]] && [[ "$1" -ne 256 ]]; then
|
||||||
|
bashio::log.warning "Halt Supervisor"
|
||||||
|
/run/s6/basedir/bin/halt
|
||||||
|
fi
|
||||||
|
|
||||||
|
bashio::log.info "Supervisor restart after closing"
|
||||||
|
|||||||
1
rootfs/etc/services.d/supervisor/run
Normal file → Executable file
1
rootfs/etc/services.d/supervisor/run
Normal file → Executable file
@@ -3,5 +3,6 @@
|
|||||||
# Start Supervisor service
|
# Start Supervisor service
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
|
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
|
||||||
|
export MALLOC_CONF="background_thread:true,metadata_thp:auto"
|
||||||
|
|
||||||
exec python3 -m supervisor
|
exec python3 -m supervisor
|
||||||
|
|||||||
11
rootfs/etc/services.d/watchdog/finish
Normal file → Executable file
11
rootfs/etc/services.d/watchdog/finish
Normal file → Executable file
@@ -1,8 +1,11 @@
|
|||||||
#!/usr/bin/execlineb -S1
|
#!/usr/bin/env bashio
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Take down the S6 supervision tree when Watchdog fails
|
# Take down the S6 supervision tree when Watchdog fails
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
if { s6-test ${1} -ne 0 }
|
|
||||||
if { s6-test ${1} -ne 256 }
|
|
||||||
|
|
||||||
s6-svscanctl -t /var/run/s6/services
|
if [[ "$1" -ne 0 ]] && [[ "$1" -ne 256 ]]; then
|
||||||
|
bashio::log.warning "Halt Supervisor (Wuff)"
|
||||||
|
/run/s6/basedir/bin/halt
|
||||||
|
fi
|
||||||
|
|
||||||
|
bashio::log.info "Watchdog restart after closing"
|
||||||
|
|||||||
4
rootfs/etc/services.d/watchdog/run
Normal file → Executable file
4
rootfs/etc/services.d/watchdog/run
Normal file → Executable file
@@ -15,7 +15,7 @@ do
|
|||||||
if [[ "${supervisor_state}" = "running" ]]; then
|
if [[ "${supervisor_state}" = "running" ]]; then
|
||||||
|
|
||||||
# Check API
|
# Check API
|
||||||
if bashio::supervisor.ping; then
|
if bashio::supervisor.ping > /dev/null; then
|
||||||
failed_count=0
|
failed_count=0
|
||||||
else
|
else
|
||||||
bashio::log.warning "Maybe found an issue on API healthy"
|
bashio::log.warning "Maybe found an issue on API healthy"
|
||||||
@@ -31,4 +31,4 @@ do
|
|||||||
|
|
||||||
done
|
done
|
||||||
|
|
||||||
basio::exit.nok "Watchdog detected issue with Supervisor - taking container down!"
|
bashio::exit.nok "Watchdog detected issue with Supervisor - taking container down!"
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -eE
|
|
||||||
|
|
||||||
DOCKER_TIMEOUT=30
|
|
||||||
DOCKER_PID=0
|
|
||||||
|
|
||||||
|
|
||||||
function start_docker() {
|
|
||||||
local starttime
|
|
||||||
local endtime
|
|
||||||
|
|
||||||
echo "Starting docker."
|
|
||||||
dockerd 2> /dev/null &
|
|
||||||
DOCKER_PID=$!
|
|
||||||
|
|
||||||
echo "Waiting for docker to initialize..."
|
|
||||||
starttime="$(date +%s)"
|
|
||||||
endtime="$(date +%s)"
|
|
||||||
until docker info >/dev/null 2>&1; do
|
|
||||||
if [ $((endtime - starttime)) -le $DOCKER_TIMEOUT ]; then
|
|
||||||
sleep 1
|
|
||||||
endtime=$(date +%s)
|
|
||||||
else
|
|
||||||
echo "Timeout while waiting for docker to come up"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "Docker was initialized"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function stop_docker() {
|
|
||||||
local starttime
|
|
||||||
local endtime
|
|
||||||
|
|
||||||
echo "Stopping in container docker..."
|
|
||||||
if [ "$DOCKER_PID" -gt 0 ] && kill -0 "$DOCKER_PID" 2> /dev/null; then
|
|
||||||
starttime="$(date +%s)"
|
|
||||||
endtime="$(date +%s)"
|
|
||||||
|
|
||||||
# Now wait for it to die
|
|
||||||
kill "$DOCKER_PID"
|
|
||||||
while kill -0 "$DOCKER_PID" 2> /dev/null; do
|
|
||||||
if [ $((endtime - starttime)) -le $DOCKER_TIMEOUT ]; then
|
|
||||||
sleep 1
|
|
||||||
endtime=$(date +%s)
|
|
||||||
else
|
|
||||||
echo "Timeout while waiting for container docker to die"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "Your host might have been left with unreleased resources"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function build_supervisor() {
|
|
||||||
docker pull homeassistant/amd64-builder:dev
|
|
||||||
|
|
||||||
docker run --rm --privileged \
|
|
||||||
-v /run/docker.sock:/run/docker.sock -v "$(pwd):/data" \
|
|
||||||
homeassistant/amd64-builder:dev \
|
|
||||||
--generic dev -t /data --test --amd64 --no-cache
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function cleanup_lastboot() {
|
|
||||||
if [[ -f /workspaces/test_supervisor/config.json ]]; then
|
|
||||||
echo "Cleaning up last boot"
|
|
||||||
cp /workspaces/test_supervisor/config.json /tmp/config.json
|
|
||||||
jq -rM 'del(.last_boot)' /tmp/config.json > /workspaces/test_supervisor/config.json
|
|
||||||
rm /tmp/config.json
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function cleanup_docker() {
|
|
||||||
echo "Cleaning up stopped containers..."
|
|
||||||
docker rm $(docker ps -a -q) || true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function setup_test_env() {
|
|
||||||
mkdir -p /workspaces/test_supervisor
|
|
||||||
|
|
||||||
echo "Start Supervisor"
|
|
||||||
docker run --rm --privileged \
|
|
||||||
--name hassio_supervisor \
|
|
||||||
--security-opt seccomp=unconfined \
|
|
||||||
--security-opt apparmor:unconfined \
|
|
||||||
-v /run/docker.sock:/run/docker.sock \
|
|
||||||
-v /run/dbus:/run/dbus \
|
|
||||||
-v "/workspaces/test_supervisor":/data \
|
|
||||||
-v /etc/machine-id:/etc/machine-id:ro \
|
|
||||||
-e SUPERVISOR_SHARE="/workspaces/test_supervisor" \
|
|
||||||
-e SUPERVISOR_NAME=hassio_supervisor \
|
|
||||||
-e SUPERVISOR_DEV=1 \
|
|
||||||
-e SUPERVISOR_MACHINE="qemux86-64" \
|
|
||||||
homeassistant/amd64-hassio-supervisor:latest
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function init_dbus() {
|
|
||||||
if pgrep dbus-daemon; then
|
|
||||||
echo "Dbus is running"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Startup dbus"
|
|
||||||
mkdir -p /var/lib/dbus
|
|
||||||
cp -f /etc/machine-id /var/lib/dbus/machine-id
|
|
||||||
|
|
||||||
# cleanups
|
|
||||||
mkdir -p /run/dbus
|
|
||||||
rm -f /run/dbus/pid
|
|
||||||
|
|
||||||
# run
|
|
||||||
dbus-daemon --system --print-address
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Start Test-Env"
|
|
||||||
|
|
||||||
start_docker
|
|
||||||
trap "stop_docker" ERR
|
|
||||||
|
|
||||||
docker system prune -f
|
|
||||||
|
|
||||||
build_supervisor
|
|
||||||
cleanup_lastboot
|
|
||||||
cleanup_docker
|
|
||||||
init_dbus
|
|
||||||
setup_test_env
|
|
||||||
stop_docker
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
source "/etc/supervisor_scripts/common"
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Update frontend
|
# Update frontend
|
||||||
@@ -9,6 +11,10 @@ cd home-assistant-polymer
|
|||||||
nvm install
|
nvm install
|
||||||
script/bootstrap
|
script/bootstrap
|
||||||
|
|
||||||
|
# Download translations
|
||||||
|
start_docker
|
||||||
|
./script/translations_download
|
||||||
|
|
||||||
# build frontend
|
# build frontend
|
||||||
cd hassio
|
cd hassio
|
||||||
./script/build_hassio
|
./script/build_hassio
|
||||||
@@ -16,3 +22,9 @@ cd hassio
|
|||||||
# Copy frontend
|
# Copy frontend
|
||||||
rm -rf ../../supervisor/api/panel/*
|
rm -rf ../../supervisor/api/panel/*
|
||||||
cp -rf build/* ../../supervisor/api/panel/
|
cp -rf build/* ../../supervisor/api/panel/
|
||||||
|
|
||||||
|
# Reset frontend git
|
||||||
|
cd ..
|
||||||
|
git reset --hard HEAD
|
||||||
|
|
||||||
|
stop_docker
|
||||||
30
setup.cfg
30
setup.cfg
@@ -1,30 +0,0 @@
|
|||||||
[isort]
|
|
||||||
multi_line_output = 3
|
|
||||||
include_trailing_comma=True
|
|
||||||
force_grid_wrap=0
|
|
||||||
line_length=88
|
|
||||||
indent = " "
|
|
||||||
not_skip = __init__.py
|
|
||||||
force_sort_within_sections = true
|
|
||||||
sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
|
||||||
default_section = THIRDPARTY
|
|
||||||
forced_separate = tests
|
|
||||||
combine_as_imports = true
|
|
||||||
use_parentheses = true
|
|
||||||
known_first_party = supervisor,tests
|
|
||||||
|
|
||||||
[flake8]
|
|
||||||
exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
|
|
||||||
doctests = True
|
|
||||||
max-line-length = 88
|
|
||||||
# E501: line too long
|
|
||||||
# W503: Line break occurred before a binary operator
|
|
||||||
# E203: Whitespace before ':'
|
|
||||||
# D202 No blank lines allowed after function docstring
|
|
||||||
# W504 line break after binary operator
|
|
||||||
ignore =
|
|
||||||
E501,
|
|
||||||
W503,
|
|
||||||
E203,
|
|
||||||
D202,
|
|
||||||
W504
|
|
||||||
73
setup.py
73
setup.py
@@ -1,57 +1,28 @@
|
|||||||
"""Home Assistant Supervisor setup."""
|
"""Home Assistant Supervisor setup."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
from supervisor.const import SUPERVISOR_VERSION
|
RE_SUPERVISOR_VERSION = re.compile(r"^SUPERVISOR_VERSION =\s*(.+)$")
|
||||||
|
|
||||||
|
SUPERVISOR_DIR = Path(__file__).parent
|
||||||
|
REQUIREMENTS_FILE = SUPERVISOR_DIR / "requirements.txt"
|
||||||
|
CONST_FILE = SUPERVISOR_DIR / "supervisor/const.py"
|
||||||
|
|
||||||
|
REQUIREMENTS = REQUIREMENTS_FILE.read_text(encoding="utf-8")
|
||||||
|
CONSTANTS = CONST_FILE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_supervisor_version():
|
||||||
|
for line in CONSTANTS.split("/n"):
|
||||||
|
if match := RE_SUPERVISOR_VERSION.match(line):
|
||||||
|
return match.group(1)
|
||||||
|
return "99.9.9dev"
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="Supervisor",
|
version=_get_supervisor_version(),
|
||||||
version=SUPERVISOR_VERSION,
|
dependencies=REQUIREMENTS.split("/n"),
|
||||||
license="BSD License",
|
|
||||||
author="The Home Assistant Authors",
|
|
||||||
author_email="hello@home-assistant.io",
|
|
||||||
url="https://home-assistant.io/",
|
|
||||||
description=("Open-source private cloud os for Home-Assistant" " based on HassOS"),
|
|
||||||
long_description=(
|
|
||||||
"A maintainless private cloud operator system that"
|
|
||||||
"setup a Home-Assistant instance. Based on HassOS"
|
|
||||||
),
|
|
||||||
classifiers=[
|
|
||||||
"Intended Audience :: End Users/Desktop",
|
|
||||||
"Intended Audience :: Developers",
|
|
||||||
"License :: OSI Approved :: Apache Software License",
|
|
||||||
"Operating System :: OS Independent",
|
|
||||||
"Topic :: Home Automation",
|
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
||||||
"Topic :: Scientific/Engineering :: Atmospheric Science",
|
|
||||||
"Development Status :: 5 - Production/Stable",
|
|
||||||
"Intended Audience :: Developers",
|
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
],
|
|
||||||
keywords=["docker", "home-assistant", "api"],
|
|
||||||
zip_safe=False,
|
|
||||||
platforms="any",
|
|
||||||
packages=[
|
|
||||||
"supervisor.addons",
|
|
||||||
"supervisor.api",
|
|
||||||
"supervisor.dbus.network",
|
|
||||||
"supervisor.dbus.payloads",
|
|
||||||
"supervisor.dbus",
|
|
||||||
"supervisor.discovery.services",
|
|
||||||
"supervisor.discovery",
|
|
||||||
"supervisor.docker",
|
|
||||||
"supervisor.homeassistant",
|
|
||||||
"supervisor.host",
|
|
||||||
"supervisor.jobs",
|
|
||||||
"supervisor.misc",
|
|
||||||
"supervisor.plugins",
|
|
||||||
"supervisor.resolution.evaluations",
|
|
||||||
"supervisor.resolution",
|
|
||||||
"supervisor.services.modules",
|
|
||||||
"supervisor.services",
|
|
||||||
"supervisor.snapshots",
|
|
||||||
"supervisor.store",
|
|
||||||
"supervisor.utils",
|
|
||||||
"supervisor",
|
|
||||||
],
|
|
||||||
include_package_data=True,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
"""Main file for Supervisor."""
|
"""Main file for Supervisor."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from supervisor import bootstrap
|
import zlib_fast
|
||||||
|
|
||||||
|
# Enable fast zlib before importing supervisor
|
||||||
|
zlib_fast.enable()
|
||||||
|
|
||||||
|
from supervisor import bootstrap # pylint: disable=wrong-import-position # noqa: E402
|
||||||
|
from supervisor.utils.logging import ( # pylint: disable=wrong-import-position # noqa: E402
|
||||||
|
activate_log_queue_handler,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,7 +37,8 @@ if __name__ == "__main__":
|
|||||||
bootstrap.initialize_logging()
|
bootstrap.initialize_logging()
|
||||||
|
|
||||||
# Init async event loop
|
# Init async event loop
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
# Check if all information are available to setup Supervisor
|
# Check if all information are available to setup Supervisor
|
||||||
bootstrap.check_environment()
|
bootstrap.check_environment()
|
||||||
@@ -37,8 +47,11 @@ if __name__ == "__main__":
|
|||||||
executor = ThreadPoolExecutor(thread_name_prefix="SyncWorker")
|
executor = ThreadPoolExecutor(thread_name_prefix="SyncWorker")
|
||||||
loop.set_default_executor(executor)
|
loop.set_default_executor(executor)
|
||||||
|
|
||||||
|
activate_log_queue_handler()
|
||||||
|
|
||||||
_LOGGER.info("Initializing Supervisor setup")
|
_LOGGER.info("Initializing Supervisor setup")
|
||||||
coresys = loop.run_until_complete(bootstrap.initialize_coresys())
|
coresys = loop.run_until_complete(bootstrap.initialize_coresys())
|
||||||
|
loop.set_debug(coresys.config.debug)
|
||||||
loop.run_until_complete(coresys.core.connect())
|
loop.run_until_complete(coresys.core.connect())
|
||||||
|
|
||||||
bootstrap.supervisor_debugger(coresys)
|
bootstrap.supervisor_debugger(coresys)
|
||||||
|
|||||||
@@ -1,424 +1 @@
|
|||||||
"""Init file for Supervisor add-ons."""
|
"""Init file for Supervisor add-ons."""
|
||||||
import asyncio
|
|
||||||
from contextlib import suppress
|
|
||||||
import logging
|
|
||||||
import tarfile
|
|
||||||
from typing import Dict, List, Optional, Union
|
|
||||||
|
|
||||||
from ..const import AddonBoot, AddonStartup, AddonState
|
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
|
||||||
from ..exceptions import (
|
|
||||||
AddonConfigurationError,
|
|
||||||
AddonsError,
|
|
||||||
AddonsNotSupportedError,
|
|
||||||
CoreDNSError,
|
|
||||||
DockerAPIError,
|
|
||||||
DockerError,
|
|
||||||
DockerNotFound,
|
|
||||||
HomeAssistantAPIError,
|
|
||||||
HostAppArmorError,
|
|
||||||
)
|
|
||||||
from ..jobs.decorator import Job, JobCondition
|
|
||||||
from ..resolution.const import ContextType, IssueType, SuggestionType
|
|
||||||
from ..store.addon import AddonStore
|
|
||||||
from ..utils import check_exception_chain
|
|
||||||
from .addon import Addon
|
|
||||||
from .data import AddonsData
|
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
AnyAddon = Union[Addon, AddonStore]
|
|
||||||
|
|
||||||
|
|
||||||
class AddonManager(CoreSysAttributes):
|
|
||||||
"""Manage add-ons inside Supervisor."""
|
|
||||||
|
|
||||||
def __init__(self, coresys: CoreSys):
|
|
||||||
"""Initialize Docker base wrapper."""
|
|
||||||
self.coresys: CoreSys = coresys
|
|
||||||
self.data: AddonsData = AddonsData(coresys)
|
|
||||||
self.local: Dict[str, Addon] = {}
|
|
||||||
self.store: Dict[str, AddonStore] = {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def all(self) -> List[AnyAddon]:
|
|
||||||
"""Return a list of all add-ons."""
|
|
||||||
addons: Dict[str, AnyAddon] = {**self.store, **self.local}
|
|
||||||
return list(addons.values())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def installed(self) -> List[Addon]:
|
|
||||||
"""Return a list of all installed add-ons."""
|
|
||||||
return list(self.local.values())
|
|
||||||
|
|
||||||
def get(self, addon_slug: str, local_only: bool = False) -> Optional[AnyAddon]:
|
|
||||||
"""Return an add-on from slug.
|
|
||||||
|
|
||||||
Prio:
|
|
||||||
1 - Local
|
|
||||||
2 - Store
|
|
||||||
"""
|
|
||||||
if addon_slug in self.local:
|
|
||||||
return self.local[addon_slug]
|
|
||||||
if not local_only:
|
|
||||||
return self.store.get(addon_slug)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def from_token(self, token: str) -> Optional[Addon]:
|
|
||||||
"""Return an add-on from Supervisor token."""
|
|
||||||
for addon in self.installed:
|
|
||||||
if token == addon.supervisor_token:
|
|
||||||
return addon
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def load(self) -> None:
|
|
||||||
"""Start up add-on management."""
|
|
||||||
tasks = []
|
|
||||||
for slug in self.data.system:
|
|
||||||
addon = self.local[slug] = Addon(self.coresys, slug)
|
|
||||||
tasks.append(addon.load())
|
|
||||||
|
|
||||||
# Run initial tasks
|
|
||||||
_LOGGER.info("Found %d installed add-ons", len(tasks))
|
|
||||||
if tasks:
|
|
||||||
await asyncio.wait(tasks)
|
|
||||||
|
|
||||||
# Sync DNS
|
|
||||||
await self.sync_dns()
|
|
||||||
|
|
||||||
async def boot(self, stage: AddonStartup) -> None:
|
|
||||||
"""Boot add-ons with mode auto."""
|
|
||||||
tasks: List[Addon] = []
|
|
||||||
for addon in self.installed:
|
|
||||||
if addon.boot != AddonBoot.AUTO or addon.startup != stage:
|
|
||||||
continue
|
|
||||||
tasks.append(addon)
|
|
||||||
|
|
||||||
# Evaluate add-ons which need to be started
|
|
||||||
_LOGGER.info("Phase '%s' starting %d add-ons", stage, len(tasks))
|
|
||||||
if not tasks:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Start Add-ons sequential
|
|
||||||
# avoid issue on slow IO
|
|
||||||
for addon in tasks:
|
|
||||||
try:
|
|
||||||
await addon.start()
|
|
||||||
except AddonsError as err:
|
|
||||||
# Check if there is an system/user issue
|
|
||||||
if check_exception_chain(
|
|
||||||
err, (DockerAPIError, DockerNotFound, AddonConfigurationError)
|
|
||||||
):
|
|
||||||
addon.boot = AddonBoot.MANUAL
|
|
||||||
addon.save_persist()
|
|
||||||
except Exception as err: # pylint: disable=broad-except
|
|
||||||
self.sys_capture_exception(err)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
_LOGGER.warning("Can't start Add-on %s", addon.slug)
|
|
||||||
|
|
||||||
await asyncio.sleep(self.sys_config.wait_boot)
|
|
||||||
|
|
||||||
async def shutdown(self, stage: AddonStartup) -> None:
|
|
||||||
"""Shutdown addons."""
|
|
||||||
tasks: List[Addon] = []
|
|
||||||
for addon in self.installed:
|
|
||||||
if addon.state != AddonState.STARTED or addon.startup != stage:
|
|
||||||
continue
|
|
||||||
tasks.append(addon)
|
|
||||||
|
|
||||||
# Evaluate add-ons which need to be stopped
|
|
||||||
_LOGGER.info("Phase '%s' stopping %d add-ons", stage, len(tasks))
|
|
||||||
if not tasks:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Stop Add-ons sequential
|
|
||||||
# avoid issue on slow IO
|
|
||||||
for addon in tasks:
|
|
||||||
try:
|
|
||||||
await addon.stop()
|
|
||||||
except Exception as err: # pylint: disable=broad-except
|
|
||||||
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
|
|
||||||
self.sys_capture_exception(err)
|
|
||||||
|
|
||||||
@Job(
|
|
||||||
conditions=[
|
|
||||||
JobCondition.FREE_SPACE,
|
|
||||||
JobCondition.INTERNET_HOST,
|
|
||||||
JobCondition.HEALTHY,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
async def install(self, slug: str) -> None:
|
|
||||||
"""Install an add-on."""
|
|
||||||
if slug in self.local:
|
|
||||||
_LOGGER.warning("Add-on %s is already installed", slug)
|
|
||||||
return
|
|
||||||
store = self.store.get(slug)
|
|
||||||
|
|
||||||
if not store:
|
|
||||||
_LOGGER.error("Add-on %s not exists", slug)
|
|
||||||
raise AddonsError()
|
|
||||||
|
|
||||||
if not store.available:
|
|
||||||
_LOGGER.error("Add-on %s not supported on that platform", slug)
|
|
||||||
raise AddonsNotSupportedError()
|
|
||||||
|
|
||||||
self.data.install(store)
|
|
||||||
addon = Addon(self.coresys, slug)
|
|
||||||
|
|
||||||
if not addon.path_data.is_dir():
|
|
||||||
_LOGGER.info(
|
|
||||||
"Creating Home Assistant add-on data folder %s", addon.path_data
|
|
||||||
)
|
|
||||||
addon.path_data.mkdir()
|
|
||||||
|
|
||||||
# Setup/Fix AppArmor profile
|
|
||||||
await addon.install_apparmor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await addon.instance.install(store.version, store.image)
|
|
||||||
except DockerError as err:
|
|
||||||
self.data.uninstall(addon)
|
|
||||||
raise AddonsError() from err
|
|
||||||
else:
|
|
||||||
self.local[slug] = addon
|
|
||||||
|
|
||||||
# Reload ingress tokens
|
|
||||||
if addon.with_ingress:
|
|
||||||
await self.sys_ingress.reload()
|
|
||||||
|
|
||||||
_LOGGER.info("Add-on '%s' successfully installed", slug)
|
|
||||||
|
|
||||||
async def uninstall(self, slug: str) -> None:
|
|
||||||
"""Remove an add-on."""
|
|
||||||
if slug not in self.local:
|
|
||||||
_LOGGER.warning("Add-on %s is not installed", slug)
|
|
||||||
return
|
|
||||||
addon = self.local[slug]
|
|
||||||
|
|
||||||
try:
|
|
||||||
await addon.instance.remove()
|
|
||||||
except DockerError as err:
|
|
||||||
raise AddonsError() from err
|
|
||||||
else:
|
|
||||||
addon.state = AddonState.UNKNOWN
|
|
||||||
|
|
||||||
await addon.remove_data()
|
|
||||||
|
|
||||||
# Cleanup audio settings
|
|
||||||
if addon.path_pulse.exists():
|
|
||||||
with suppress(OSError):
|
|
||||||
addon.path_pulse.unlink()
|
|
||||||
|
|
||||||
# Cleanup AppArmor profile
|
|
||||||
with suppress(HostAppArmorError):
|
|
||||||
await addon.uninstall_apparmor()
|
|
||||||
|
|
||||||
# Cleanup Ingress panel from sidebar
|
|
||||||
if addon.ingress_panel:
|
|
||||||
addon.ingress_panel = False
|
|
||||||
with suppress(HomeAssistantAPIError):
|
|
||||||
await self.sys_ingress.update_hass_panel(addon)
|
|
||||||
|
|
||||||
# Cleanup Ingress dynamic port assignment
|
|
||||||
if addon.with_ingress:
|
|
||||||
self.sys_create_task(self.sys_ingress.reload())
|
|
||||||
self.sys_ingress.del_dynamic_port(slug)
|
|
||||||
|
|
||||||
# Cleanup discovery data
|
|
||||||
for message in self.sys_discovery.list_messages:
|
|
||||||
if message.addon != addon.slug:
|
|
||||||
continue
|
|
||||||
self.sys_discovery.remove(message)
|
|
||||||
|
|
||||||
# Cleanup services data
|
|
||||||
for service in self.sys_services.list_services:
|
|
||||||
if addon.slug not in service.active:
|
|
||||||
continue
|
|
||||||
service.del_service_data(addon)
|
|
||||||
|
|
||||||
self.data.uninstall(addon)
|
|
||||||
self.local.pop(slug)
|
|
||||||
|
|
||||||
_LOGGER.info("Add-on '%s' successfully removed", slug)
|
|
||||||
|
|
||||||
@Job(
|
|
||||||
conditions=[
|
|
||||||
JobCondition.FREE_SPACE,
|
|
||||||
JobCondition.INTERNET_HOST,
|
|
||||||
JobCondition.HEALTHY,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
async def update(self, slug: str) -> None:
|
|
||||||
"""Update add-on."""
|
|
||||||
if slug not in self.local:
|
|
||||||
_LOGGER.error("Add-on %s is not installed", slug)
|
|
||||||
raise AddonsError()
|
|
||||||
addon = self.local[slug]
|
|
||||||
|
|
||||||
if addon.is_detached:
|
|
||||||
_LOGGER.error("Add-on %s is not available inside store", slug)
|
|
||||||
raise AddonsError()
|
|
||||||
store = self.store[slug]
|
|
||||||
|
|
||||||
if addon.version == store.version:
|
|
||||||
_LOGGER.warning("No update available for add-on %s", slug)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if available, Maybe something have changed
|
|
||||||
if not store.available:
|
|
||||||
_LOGGER.error("Add-on %s not supported on that platform", slug)
|
|
||||||
raise AddonsNotSupportedError()
|
|
||||||
|
|
||||||
# Update instance
|
|
||||||
last_state: AddonState = addon.state
|
|
||||||
try:
|
|
||||||
await addon.instance.update(store.version, store.image)
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
with suppress(DockerError):
|
|
||||||
await addon.instance.cleanup()
|
|
||||||
except DockerError as err:
|
|
||||||
raise AddonsError() from err
|
|
||||||
else:
|
|
||||||
self.data.update(store)
|
|
||||||
_LOGGER.info("Add-on '%s' successfully updated", slug)
|
|
||||||
|
|
||||||
# Setup/Fix AppArmor profile
|
|
||||||
await addon.install_apparmor()
|
|
||||||
|
|
||||||
# restore state
|
|
||||||
if last_state == AddonState.STARTED:
|
|
||||||
await addon.start()
|
|
||||||
|
|
||||||
@Job(
|
|
||||||
conditions=[
|
|
||||||
JobCondition.FREE_SPACE,
|
|
||||||
JobCondition.INTERNET_HOST,
|
|
||||||
JobCondition.HEALTHY,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
async def rebuild(self, slug: str) -> None:
|
|
||||||
"""Perform a rebuild of local build add-on."""
|
|
||||||
if slug not in self.local:
|
|
||||||
_LOGGER.error("Add-on %s is not installed", slug)
|
|
||||||
raise AddonsError()
|
|
||||||
addon = self.local[slug]
|
|
||||||
|
|
||||||
if addon.is_detached:
|
|
||||||
_LOGGER.error("Add-on %s is not available inside store", slug)
|
|
||||||
raise AddonsError()
|
|
||||||
store = self.store[slug]
|
|
||||||
|
|
||||||
# Check if a rebuild is possible now
|
|
||||||
if addon.version != store.version:
|
|
||||||
_LOGGER.error("Version changed, use Update instead Rebuild")
|
|
||||||
raise AddonsError()
|
|
||||||
if not addon.need_build:
|
|
||||||
_LOGGER.error("Can't rebuild a image based add-on")
|
|
||||||
raise AddonsNotSupportedError()
|
|
||||||
|
|
||||||
# remove docker container but not addon config
|
|
||||||
last_state: AddonState = addon.state
|
|
||||||
try:
|
|
||||||
await addon.instance.remove()
|
|
||||||
await addon.instance.install(addon.version)
|
|
||||||
except DockerError as err:
|
|
||||||
raise AddonsError() from err
|
|
||||||
else:
|
|
||||||
self.data.update(store)
|
|
||||||
_LOGGER.info("Add-on '%s' successfully rebuilt", slug)
|
|
||||||
|
|
||||||
# restore state
|
|
||||||
if last_state == AddonState.STARTED:
|
|
||||||
await addon.start()
|
|
||||||
|
|
||||||
@Job(
|
|
||||||
conditions=[
|
|
||||||
JobCondition.FREE_SPACE,
|
|
||||||
JobCondition.INTERNET_HOST,
|
|
||||||
JobCondition.HEALTHY,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
async def restore(self, slug: str, tar_file: tarfile.TarFile) -> None:
|
|
||||||
"""Restore state of an add-on."""
|
|
||||||
if slug not in self.local:
|
|
||||||
_LOGGER.debug("Add-on %s is not local available for restore", slug)
|
|
||||||
addon = Addon(self.coresys, slug)
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("Add-on %s is local available for restore", slug)
|
|
||||||
addon = self.local[slug]
|
|
||||||
|
|
||||||
await addon.restore(tar_file)
|
|
||||||
|
|
||||||
# Check if new
|
|
||||||
if slug not in self.local:
|
|
||||||
_LOGGER.info("Detect new Add-on after restore %s", slug)
|
|
||||||
self.local[slug] = addon
|
|
||||||
|
|
||||||
# Update ingress
|
|
||||||
if addon.with_ingress:
|
|
||||||
await self.sys_ingress.reload()
|
|
||||||
with suppress(HomeAssistantAPIError):
|
|
||||||
await self.sys_ingress.update_hass_panel(addon)
|
|
||||||
|
|
||||||
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST])
|
|
||||||
async def repair(self) -> None:
|
|
||||||
"""Repair local add-ons."""
|
|
||||||
needs_repair: List[Addon] = []
|
|
||||||
|
|
||||||
# Evaluate Add-ons to repair
|
|
||||||
for addon in self.installed:
|
|
||||||
if await addon.instance.exists():
|
|
||||||
continue
|
|
||||||
needs_repair.append(addon)
|
|
||||||
|
|
||||||
_LOGGER.info("Found %d add-ons to repair", len(needs_repair))
|
|
||||||
if not needs_repair:
|
|
||||||
return
|
|
||||||
|
|
||||||
for addon in needs_repair:
|
|
||||||
_LOGGER.info("Repairing for add-on: %s", addon.slug)
|
|
||||||
with suppress(DockerError, KeyError):
|
|
||||||
# Need pull a image again
|
|
||||||
if not addon.need_build:
|
|
||||||
await addon.instance.install(addon.version, addon.image)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Need local lookup
|
|
||||||
if addon.need_build and not addon.is_detached:
|
|
||||||
store = self.store[addon.slug]
|
|
||||||
# If this add-on is available for rebuild
|
|
||||||
if addon.version == store.version:
|
|
||||||
await addon.instance.install(addon.version, addon.image)
|
|
||||||
continue
|
|
||||||
|
|
||||||
_LOGGER.error("Can't repair %s", addon.slug)
|
|
||||||
with suppress(AddonsError):
|
|
||||||
await self.uninstall(addon.slug)
|
|
||||||
|
|
||||||
async def sync_dns(self) -> None:
|
|
||||||
"""Sync add-ons DNS names."""
|
|
||||||
# Update hosts
|
|
||||||
for addon in self.installed:
|
|
||||||
try:
|
|
||||||
if not await addon.instance.is_running():
|
|
||||||
continue
|
|
||||||
except DockerError as err:
|
|
||||||
_LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err)
|
|
||||||
self.sys_resolution.create_issue(
|
|
||||||
IssueType.CORRUPT_DOCKER,
|
|
||||||
ContextType.ADDON,
|
|
||||||
reference=addon.slug,
|
|
||||||
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
|
||||||
)
|
|
||||||
self.sys_capture_exception(err)
|
|
||||||
else:
|
|
||||||
self.sys_plugins.dns.add_host(
|
|
||||||
ipv4=addon.ip_address, names=[addon.hostname], write=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write hosts files
|
|
||||||
with suppress(CoreDNSError):
|
|
||||||
self.sys_plugins.dns.write_hosts()
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,32 @@
|
|||||||
"""Supervisor add-on build environment."""
|
"""Supervisor add-on build environment."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import cached_property
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Dict
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ..const import ATTR_ARGS, ATTR_BUILD_FROM, ATTR_SQUASH, META_ADDON
|
from awesomeversion import AwesomeVersion
|
||||||
|
|
||||||
|
from ..const import (
|
||||||
|
ATTR_ARGS,
|
||||||
|
ATTR_BUILD_FROM,
|
||||||
|
ATTR_LABELS,
|
||||||
|
ATTR_SQUASH,
|
||||||
|
FILE_SUFFIX_CONFIGURATION,
|
||||||
|
META_ADDON,
|
||||||
|
)
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
from ..utils.json import JsonConfig
|
from ..docker.interface import MAP_ARCH
|
||||||
|
from ..exceptions import ConfigurationFileError, HassioArchNotFound
|
||||||
|
from ..utils.common import FileConfiguration, find_one_filetype
|
||||||
from .validate import SCHEMA_BUILD_CONFIG
|
from .validate import SCHEMA_BUILD_CONFIG
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from . import AnyAddon
|
from . import AnyAddon
|
||||||
|
|
||||||
|
|
||||||
class AddonBuild(JsonConfig, CoreSysAttributes):
|
class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||||
"""Handle build options for add-ons."""
|
"""Handle build options for add-ons."""
|
||||||
|
|
||||||
def __init__(self, coresys: CoreSys, addon: AnyAddon) -> None:
|
def __init__(self, coresys: CoreSys, addon: AnyAddon) -> None:
|
||||||
@@ -21,20 +34,46 @@ class AddonBuild(JsonConfig, CoreSysAttributes):
|
|||||||
self.coresys: CoreSys = coresys
|
self.coresys: CoreSys = coresys
|
||||||
self.addon = addon
|
self.addon = addon
|
||||||
|
|
||||||
super().__init__(
|
try:
|
||||||
Path(self.addon.path_location, "build.json"), SCHEMA_BUILD_CONFIG
|
build_file = find_one_filetype(
|
||||||
|
self.addon.path_location, "build", FILE_SUFFIX_CONFIGURATION
|
||||||
)
|
)
|
||||||
|
except ConfigurationFileError:
|
||||||
|
build_file = self.addon.path_location / "build.json"
|
||||||
|
|
||||||
|
super().__init__(build_file, SCHEMA_BUILD_CONFIG)
|
||||||
|
|
||||||
def save_data(self):
|
def save_data(self):
|
||||||
"""Ignore save function."""
|
"""Ignore save function."""
|
||||||
raise RuntimeError()
|
raise RuntimeError()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def arch(self) -> str:
|
||||||
|
"""Return arch of the add-on."""
|
||||||
|
return self.sys_arch.match(self.addon.arch)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_image(self) -> str:
|
def base_image(self) -> str:
|
||||||
"""Return base image for this add-on."""
|
"""Return base image for this add-on."""
|
||||||
return self._data[ATTR_BUILD_FROM].get(
|
if not self._data[ATTR_BUILD_FROM]:
|
||||||
self.sys_arch.default, f"homeassistant/{self.sys_arch.default}-base:latest"
|
return f"ghcr.io/home-assistant/{self.sys_arch.default}-base:latest"
|
||||||
|
|
||||||
|
if isinstance(self._data[ATTR_BUILD_FROM], str):
|
||||||
|
return self._data[ATTR_BUILD_FROM]
|
||||||
|
|
||||||
|
# Evaluate correct base image
|
||||||
|
if self.arch not in self._data[ATTR_BUILD_FROM]:
|
||||||
|
raise HassioArchNotFound(
|
||||||
|
f"Add-on {self.addon.slug} is not supported on {self.arch}"
|
||||||
)
|
)
|
||||||
|
return self._data[ATTR_BUILD_FROM][self.arch]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dockerfile(self) -> Path:
|
||||||
|
"""Return Dockerfile path."""
|
||||||
|
if self.addon.path_location.joinpath(f"Dockerfile.{self.arch}").exists():
|
||||||
|
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
|
||||||
|
return self.addon.path_location.joinpath("Dockerfile")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def squash(self) -> bool:
|
def squash(self) -> bool:
|
||||||
@@ -42,24 +81,45 @@ class AddonBuild(JsonConfig, CoreSysAttributes):
|
|||||||
return self._data[ATTR_SQUASH]
|
return self._data[ATTR_SQUASH]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def additional_args(self) -> Dict[str, str]:
|
def additional_args(self) -> dict[str, str]:
|
||||||
"""Return additional Docker build arguments."""
|
"""Return additional Docker build arguments."""
|
||||||
return self._data[ATTR_ARGS]
|
return self._data[ATTR_ARGS]
|
||||||
|
|
||||||
def get_docker_args(self, version):
|
@property
|
||||||
|
def additional_labels(self) -> dict[str, str]:
|
||||||
|
"""Return additional Docker labels."""
|
||||||
|
return self._data[ATTR_LABELS]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""Return true if the build env is valid."""
|
||||||
|
try:
|
||||||
|
return all(
|
||||||
|
[
|
||||||
|
self.addon.path_location.is_dir(),
|
||||||
|
self.dockerfile.is_file(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
except HassioArchNotFound:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_docker_args(self, version: AwesomeVersion, image: str | None = None):
|
||||||
"""Create a dict with Docker build arguments."""
|
"""Create a dict with Docker build arguments."""
|
||||||
args = {
|
args = {
|
||||||
"path": str(self.addon.path_location),
|
"path": str(self.addon.path_location),
|
||||||
"tag": f"{self.addon.image}:{version}",
|
"tag": f"{image or self.addon.image}:{version!s}",
|
||||||
|
"dockerfile": str(self.dockerfile),
|
||||||
"pull": True,
|
"pull": True,
|
||||||
"forcerm": True,
|
"forcerm": not self.sys_dev,
|
||||||
"squash": self.squash,
|
"squash": self.squash,
|
||||||
|
"platform": MAP_ARCH[self.arch],
|
||||||
"labels": {
|
"labels": {
|
||||||
"io.hass.version": version,
|
"io.hass.version": version,
|
||||||
"io.hass.arch": self.sys_arch.default,
|
"io.hass.arch": self.arch,
|
||||||
"io.hass.type": META_ADDON,
|
"io.hass.type": META_ADDON,
|
||||||
"io.hass.name": self._fix_label("name"),
|
"io.hass.name": self._fix_label("name"),
|
||||||
"io.hass.description": self._fix_label("description"),
|
"io.hass.description": self._fix_label("description"),
|
||||||
|
**self.additional_labels,
|
||||||
},
|
},
|
||||||
"buildargs": {
|
"buildargs": {
|
||||||
"BUILD_FROM": self.base_image,
|
"BUILD_FROM": self.base_image,
|
||||||
|
|||||||
11
supervisor/addons/configuration.py
Normal file
11
supervisor/addons/configuration.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Confgiuration Objects for Addon Config."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FolderMapping:
|
||||||
|
"""Represent folder mapping configuration."""
|
||||||
|
|
||||||
|
path: str | None
|
||||||
|
read_only: bool
|
||||||
49
supervisor/addons/const.py
Normal file
49
supervisor/addons/const.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Add-on static data."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from ..jobs.const import JobCondition
|
||||||
|
|
||||||
|
|
||||||
|
class AddonBackupMode(StrEnum):
|
||||||
|
"""Backup mode of an Add-on."""
|
||||||
|
|
||||||
|
HOT = "hot"
|
||||||
|
COLD = "cold"
|
||||||
|
|
||||||
|
|
||||||
|
class MappingType(StrEnum):
|
||||||
|
"""Mapping type of an Add-on Folder."""
|
||||||
|
|
||||||
|
DATA = "data"
|
||||||
|
CONFIG = "config"
|
||||||
|
SSL = "ssl"
|
||||||
|
ADDONS = "addons"
|
||||||
|
BACKUP = "backup"
|
||||||
|
SHARE = "share"
|
||||||
|
MEDIA = "media"
|
||||||
|
HOMEASSISTANT_CONFIG = "homeassistant_config"
|
||||||
|
ALL_ADDON_CONFIGS = "all_addon_configs"
|
||||||
|
ADDON_CONFIG = "addon_config"
|
||||||
|
|
||||||
|
|
||||||
|
ATTR_BACKUP = "backup"
|
||||||
|
ATTR_BREAKING_VERSIONS = "breaking_versions"
|
||||||
|
ATTR_CODENOTARY = "codenotary"
|
||||||
|
ATTR_READ_ONLY = "read_only"
|
||||||
|
ATTR_PATH = "path"
|
||||||
|
WATCHDOG_RETRY_SECONDS = 10
|
||||||
|
WATCHDOG_MAX_ATTEMPTS = 5
|
||||||
|
WATCHDOG_THROTTLE_PERIOD = timedelta(minutes=30)
|
||||||
|
WATCHDOG_THROTTLE_MAX_CALLS = 10
|
||||||
|
|
||||||
|
ADDON_UPDATE_CONDITIONS = [
|
||||||
|
JobCondition.FREE_SPACE,
|
||||||
|
JobCondition.HEALTHY,
|
||||||
|
JobCondition.INTERNET_HOST,
|
||||||
|
JobCondition.PLUGINS_UPDATED,
|
||||||
|
JobCondition.SUPERVISOR_UPDATED,
|
||||||
|
]
|
||||||
|
|
||||||
|
RE_SLUG = r"[-_.A-Za-z0-9]+"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Init file for Supervisor add-on data."""
|
"""Init file for Supervisor add-on data."""
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
import logging
|
from typing import Any
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
ATTR_IMAGE,
|
ATTR_IMAGE,
|
||||||
@@ -13,16 +13,14 @@ from ..const import (
|
|||||||
)
|
)
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
from ..store.addon import AddonStore
|
from ..store.addon import AddonStore
|
||||||
from ..utils.json import JsonConfig
|
from ..utils.common import FileConfiguration
|
||||||
from .addon import Addon
|
from .addon import Addon
|
||||||
from .validate import SCHEMA_ADDONS_FILE
|
from .validate import SCHEMA_ADDONS_FILE
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
Config = dict[str, Any]
|
||||||
|
|
||||||
Config = Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
class AddonsData(JsonConfig, CoreSysAttributes):
|
class AddonsData(FileConfiguration, CoreSysAttributes):
|
||||||
"""Hold data for installed Add-ons inside Supervisor."""
|
"""Hold data for installed Add-ons inside Supervisor."""
|
||||||
|
|
||||||
def __init__(self, coresys: CoreSys):
|
def __init__(self, coresys: CoreSys):
|
||||||
|
|||||||
388
supervisor/addons/manager.py
Normal file
388
supervisor/addons/manager.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""Supervisor add-on manager."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from contextlib import suppress
|
||||||
|
import logging
|
||||||
|
import tarfile
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from ..const import AddonBoot, AddonStartup, AddonState
|
||||||
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
|
from ..exceptions import (
|
||||||
|
AddonConfigurationError,
|
||||||
|
AddonsError,
|
||||||
|
AddonsJobError,
|
||||||
|
AddonsNotSupportedError,
|
||||||
|
CoreDNSError,
|
||||||
|
DockerAPIError,
|
||||||
|
DockerError,
|
||||||
|
DockerNotFound,
|
||||||
|
HassioError,
|
||||||
|
HomeAssistantAPIError,
|
||||||
|
)
|
||||||
|
from ..jobs.decorator import Job, JobCondition
|
||||||
|
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||||
|
from ..store.addon import AddonStore
|
||||||
|
from ..utils import check_exception_chain
|
||||||
|
from ..utils.sentry import capture_exception
|
||||||
|
from .addon import Addon
|
||||||
|
from .const import ADDON_UPDATE_CONDITIONS
|
||||||
|
from .data import AddonsData
|
||||||
|
|
||||||
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AnyAddon = Union[Addon, AddonStore]
|
||||||
|
|
||||||
|
|
||||||
|
class AddonManager(CoreSysAttributes):
|
||||||
|
"""Manage add-ons inside Supervisor."""
|
||||||
|
|
||||||
|
def __init__(self, coresys: CoreSys):
|
||||||
|
"""Initialize Docker base wrapper."""
|
||||||
|
self.coresys: CoreSys = coresys
|
||||||
|
self.data: AddonsData = AddonsData(coresys)
|
||||||
|
self.local: dict[str, Addon] = {}
|
||||||
|
self.store: dict[str, AddonStore] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all(self) -> list[AnyAddon]:
|
||||||
|
"""Return a list of all add-ons."""
|
||||||
|
addons: dict[str, AnyAddon] = {**self.store, **self.local}
|
||||||
|
return list(addons.values())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def installed(self) -> list[Addon]:
|
||||||
|
"""Return a list of all installed add-ons."""
|
||||||
|
return list(self.local.values())
|
||||||
|
|
||||||
|
def get(self, addon_slug: str, local_only: bool = False) -> AnyAddon | None:
|
||||||
|
"""Return an add-on from slug.
|
||||||
|
|
||||||
|
Prio:
|
||||||
|
1 - Local
|
||||||
|
2 - Store
|
||||||
|
"""
|
||||||
|
if addon_slug in self.local:
|
||||||
|
return self.local[addon_slug]
|
||||||
|
if not local_only:
|
||||||
|
return self.store.get(addon_slug)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def from_token(self, token: str) -> Addon | None:
|
||||||
|
"""Return an add-on from Supervisor token."""
|
||||||
|
for addon in self.installed:
|
||||||
|
if token == addon.supervisor_token:
|
||||||
|
return addon
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def load(self) -> None:
|
||||||
|
"""Start up add-on management."""
|
||||||
|
# Refresh cache for all store addons
|
||||||
|
tasks: list[Awaitable[None]] = [
|
||||||
|
store.refresh_path_cache() for store in self.store.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Load all installed addons
|
||||||
|
for slug in self.data.system:
|
||||||
|
addon = self.local[slug] = Addon(self.coresys, slug)
|
||||||
|
tasks.append(addon.load())
|
||||||
|
|
||||||
|
# Run initial tasks
|
||||||
|
_LOGGER.info("Found %d installed add-ons", len(self.data.system))
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
# Sync DNS
|
||||||
|
await self.sync_dns()
|
||||||
|
|
||||||
|
async def boot(self, stage: AddonStartup) -> None:
|
||||||
|
"""Boot add-ons with mode auto."""
|
||||||
|
tasks: list[Addon] = []
|
||||||
|
for addon in self.installed:
|
||||||
|
if addon.boot != AddonBoot.AUTO or addon.startup != stage:
|
||||||
|
continue
|
||||||
|
tasks.append(addon)
|
||||||
|
|
||||||
|
# Evaluate add-ons which need to be started
|
||||||
|
_LOGGER.info("Phase '%s' starting %d add-ons", stage, len(tasks))
|
||||||
|
if not tasks:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start Add-ons sequential
|
||||||
|
# avoid issue on slow IO
|
||||||
|
# Config.wait_boot is deprecated. Until addons update with healthchecks,
|
||||||
|
# add a sleep task for it to keep the same minimum amount of wait time
|
||||||
|
wait_boot: list[Awaitable[None]] = [asyncio.sleep(self.sys_config.wait_boot)]
|
||||||
|
for addon in tasks:
|
||||||
|
try:
|
||||||
|
if start_task := await addon.start():
|
||||||
|
wait_boot.append(start_task)
|
||||||
|
except AddonsError as err:
|
||||||
|
# Check if there is an system/user issue
|
||||||
|
if check_exception_chain(
|
||||||
|
err, (DockerAPIError, DockerNotFound, AddonConfigurationError)
|
||||||
|
):
|
||||||
|
addon.boot = AddonBoot.MANUAL
|
||||||
|
addon.save_persist()
|
||||||
|
except HassioError:
|
||||||
|
pass # These are already handled
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_LOGGER.warning("Can't start Add-on %s", addon.slug)
|
||||||
|
|
||||||
|
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
|
||||||
|
await asyncio.gather(*wait_boot, return_exceptions=True)
|
||||||
|
|
||||||
|
async def shutdown(self, stage: AddonStartup) -> None:
|
||||||
|
"""Shutdown addons."""
|
||||||
|
tasks: list[Addon] = []
|
||||||
|
for addon in self.installed:
|
||||||
|
if addon.state != AddonState.STARTED or addon.startup != stage:
|
||||||
|
continue
|
||||||
|
tasks.append(addon)
|
||||||
|
|
||||||
|
# Evaluate add-ons which need to be stopped
|
||||||
|
_LOGGER.info("Phase '%s' stopping %d add-ons", stage, len(tasks))
|
||||||
|
if not tasks:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stop Add-ons sequential
|
||||||
|
# avoid issue on slow IO
|
||||||
|
for addon in tasks:
|
||||||
|
try:
|
||||||
|
await addon.stop()
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
|
||||||
|
capture_exception(err)
|
||||||
|
|
||||||
|
@Job(
|
||||||
|
name="addon_manager_install",
|
||||||
|
conditions=ADDON_UPDATE_CONDITIONS,
|
||||||
|
on_condition=AddonsJobError,
|
||||||
|
)
|
||||||
|
async def install(self, slug: str) -> None:
|
||||||
|
"""Install an add-on."""
|
||||||
|
self.sys_jobs.current.reference = slug
|
||||||
|
|
||||||
|
if slug in self.local:
|
||||||
|
raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning)
|
||||||
|
store = self.store.get(slug)
|
||||||
|
|
||||||
|
if not store:
|
||||||
|
raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error)
|
||||||
|
|
||||||
|
store.validate_availability()
|
||||||
|
|
||||||
|
await Addon(self.coresys, slug).install()
|
||||||
|
|
||||||
|
_LOGGER.info("Add-on '%s' successfully installed", slug)
|
||||||
|
|
||||||
|
async def uninstall(self, slug: str, *, remove_config: bool = False) -> None:
|
||||||
|
"""Remove an add-on."""
|
||||||
|
if slug not in self.local:
|
||||||
|
_LOGGER.warning("Add-on %s is not installed", slug)
|
||||||
|
return
|
||||||
|
|
||||||
|
shared_image = any(
|
||||||
|
self.local[slug].image == addon.image
|
||||||
|
and self.local[slug].version == addon.version
|
||||||
|
for addon in self.installed
|
||||||
|
if addon.slug != slug
|
||||||
|
)
|
||||||
|
await self.local[slug].uninstall(
|
||||||
|
remove_config=remove_config, remove_image=not shared_image
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.info("Add-on '%s' successfully removed", slug)
|
||||||
|
|
||||||
|
@Job(
|
||||||
|
name="addon_manager_update",
|
||||||
|
conditions=ADDON_UPDATE_CONDITIONS,
|
||||||
|
on_condition=AddonsJobError,
|
||||||
|
)
|
||||||
|
async def update(
|
||||||
|
self, slug: str, backup: bool | None = False
|
||||||
|
) -> asyncio.Task | None:
|
||||||
|
"""Update add-on.
|
||||||
|
|
||||||
|
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||||
|
if addon is started after update. Else nothing is returned.
|
||||||
|
"""
|
||||||
|
self.sys_jobs.current.reference = slug
|
||||||
|
|
||||||
|
if slug not in self.local:
|
||||||
|
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||||
|
addon = self.local[slug]
|
||||||
|
|
||||||
|
if addon.is_detached:
|
||||||
|
raise AddonsError(
|
||||||
|
f"Add-on {slug} is not available inside store", _LOGGER.error
|
||||||
|
)
|
||||||
|
store = self.store[slug]
|
||||||
|
|
||||||
|
if addon.version == store.version:
|
||||||
|
raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning)
|
||||||
|
|
||||||
|
# Check if available, Maybe something have changed
|
||||||
|
store.validate_availability()
|
||||||
|
|
||||||
|
if backup:
|
||||||
|
await self.sys_backups.do_backup_partial(
|
||||||
|
name=f"addon_{addon.slug}_{addon.version}",
|
||||||
|
homeassistant=False,
|
||||||
|
addons=[addon.slug],
|
||||||
|
)
|
||||||
|
|
||||||
|
return await addon.update()
|
||||||
|
|
||||||
|
@Job(
|
||||||
|
name="addon_manager_rebuild",
|
||||||
|
conditions=[
|
||||||
|
JobCondition.FREE_SPACE,
|
||||||
|
JobCondition.INTERNET_HOST,
|
||||||
|
JobCondition.HEALTHY,
|
||||||
|
],
|
||||||
|
on_condition=AddonsJobError,
|
||||||
|
)
|
||||||
|
async def rebuild(self, slug: str) -> asyncio.Task | None:
|
||||||
|
"""Perform a rebuild of local build add-on.
|
||||||
|
|
||||||
|
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||||
|
if addon is started after rebuild. Else nothing is returned.
|
||||||
|
"""
|
||||||
|
self.sys_jobs.current.reference = slug
|
||||||
|
|
||||||
|
if slug not in self.local:
|
||||||
|
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||||
|
addon = self.local[slug]
|
||||||
|
|
||||||
|
if addon.is_detached:
|
||||||
|
raise AddonsError(
|
||||||
|
f"Add-on {slug} is not available inside store", _LOGGER.error
|
||||||
|
)
|
||||||
|
store = self.store[slug]
|
||||||
|
|
||||||
|
# Check if a rebuild is possible now
|
||||||
|
if addon.version != store.version:
|
||||||
|
raise AddonsError(
|
||||||
|
"Version changed, use Update instead Rebuild", _LOGGER.error
|
||||||
|
)
|
||||||
|
if not addon.need_build:
|
||||||
|
raise AddonsNotSupportedError(
|
||||||
|
"Can't rebuild a image based add-on", _LOGGER.error
|
||||||
|
)
|
||||||
|
|
||||||
|
return await addon.rebuild()
|
||||||
|
|
||||||
|
@Job(
|
||||||
|
name="addon_manager_restore",
|
||||||
|
conditions=[
|
||||||
|
JobCondition.FREE_SPACE,
|
||||||
|
JobCondition.INTERNET_HOST,
|
||||||
|
JobCondition.HEALTHY,
|
||||||
|
],
|
||||||
|
on_condition=AddonsJobError,
|
||||||
|
)
|
||||||
|
async def restore(
|
||||||
|
self, slug: str, tar_file: tarfile.TarFile
|
||||||
|
) -> asyncio.Task | None:
|
||||||
|
"""Restore state of an add-on.
|
||||||
|
|
||||||
|
Returns a Task that completes when addon has state 'started' (see addon.start)
|
||||||
|
if addon is started after restore. Else nothing is returned.
|
||||||
|
"""
|
||||||
|
self.sys_jobs.current.reference = slug
|
||||||
|
|
||||||
|
if slug not in self.local:
|
||||||
|
_LOGGER.debug("Add-on %s is not local available for restore", slug)
|
||||||
|
addon = Addon(self.coresys, slug)
|
||||||
|
had_ingress = False
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Add-on %s is local available for restore", slug)
|
||||||
|
addon = self.local[slug]
|
||||||
|
had_ingress = addon.ingress_panel
|
||||||
|
|
||||||
|
wait_for_start = await addon.restore(tar_file)
|
||||||
|
|
||||||
|
# Check if new
|
||||||
|
if slug not in self.local:
|
||||||
|
_LOGGER.info("Detect new Add-on after restore %s", slug)
|
||||||
|
self.local[slug] = addon
|
||||||
|
|
||||||
|
# Update ingress
|
||||||
|
if had_ingress != addon.ingress_panel:
|
||||||
|
await self.sys_ingress.reload()
|
||||||
|
with suppress(HomeAssistantAPIError):
|
||||||
|
await self.sys_ingress.update_hass_panel(addon)
|
||||||
|
|
||||||
|
return wait_for_start
|
||||||
|
|
||||||
|
@Job(
|
||||||
|
name="addon_manager_repair",
|
||||||
|
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST],
|
||||||
|
)
|
||||||
|
async def repair(self) -> None:
|
||||||
|
"""Repair local add-ons."""
|
||||||
|
needs_repair: list[Addon] = []
|
||||||
|
|
||||||
|
# Evaluate Add-ons to repair
|
||||||
|
for addon in self.installed:
|
||||||
|
if await addon.instance.exists():
|
||||||
|
continue
|
||||||
|
needs_repair.append(addon)
|
||||||
|
|
||||||
|
_LOGGER.info("Found %d add-ons to repair", len(needs_repair))
|
||||||
|
if not needs_repair:
|
||||||
|
return
|
||||||
|
|
||||||
|
for addon in needs_repair:
|
||||||
|
_LOGGER.info("Repairing for add-on: %s", addon.slug)
|
||||||
|
with suppress(DockerError, KeyError):
|
||||||
|
# Need pull a image again
|
||||||
|
if not addon.need_build:
|
||||||
|
await addon.instance.install(addon.version, addon.image)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Need local lookup
|
||||||
|
if addon.need_build and not addon.is_detached:
|
||||||
|
store = self.store[addon.slug]
|
||||||
|
# If this add-on is available for rebuild
|
||||||
|
if addon.version == store.version:
|
||||||
|
await addon.instance.install(addon.version, addon.image)
|
||||||
|
continue
|
||||||
|
|
||||||
|
_LOGGER.error("Can't repair %s", addon.slug)
|
||||||
|
with suppress(AddonsError):
|
||||||
|
await self.uninstall(addon.slug)
|
||||||
|
|
||||||
|
async def sync_dns(self) -> None:
|
||||||
|
"""Sync add-ons DNS names."""
|
||||||
|
# Update hosts
|
||||||
|
add_host_coros: list[Awaitable[None]] = []
|
||||||
|
for addon in self.installed:
|
||||||
|
try:
|
||||||
|
if not await addon.instance.is_running():
|
||||||
|
continue
|
||||||
|
except DockerError as err:
|
||||||
|
_LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err)
|
||||||
|
self.sys_resolution.create_issue(
|
||||||
|
IssueType.CORRUPT_DOCKER,
|
||||||
|
ContextType.ADDON,
|
||||||
|
reference=addon.slug,
|
||||||
|
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
||||||
|
)
|
||||||
|
capture_exception(err)
|
||||||
|
else:
|
||||||
|
add_host_coros.append(
|
||||||
|
self.sys_plugins.dns.add_host(
|
||||||
|
ipv4=addon.ip_address, names=[addon.hostname], write=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(*add_host_coros)
|
||||||
|
|
||||||
|
# Write hosts files
|
||||||
|
with suppress(CoreDNSError):
|
||||||
|
await self.sys_plugins.dns.write_hosts()
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
"""Init file for Supervisor add-ons."""
|
"""Init file for Supervisor add-ons."""
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Awaitable, Dict, List, Optional
|
|
||||||
|
|
||||||
from packaging import version as pkg_version
|
from abc import ABC, abstractmethod
|
||||||
import voluptuous as vol
|
from collections import defaultdict
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||||
|
|
||||||
|
from supervisor.utils.dt import utc_from_timestamp
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
ATTR_ADVANCED,
|
ATTR_ADVANCED,
|
||||||
@@ -12,7 +19,9 @@ from ..const import (
|
|||||||
ATTR_ARCH,
|
ATTR_ARCH,
|
||||||
ATTR_AUDIO,
|
ATTR_AUDIO,
|
||||||
ATTR_AUTH_API,
|
ATTR_AUTH_API,
|
||||||
ATTR_AUTO_UART,
|
ATTR_BACKUP_EXCLUDE,
|
||||||
|
ATTR_BACKUP_POST,
|
||||||
|
ATTR_BACKUP_PRE,
|
||||||
ATTR_BOOT,
|
ATTR_BOOT,
|
||||||
ATTR_DESCRIPTON,
|
ATTR_DESCRIPTON,
|
||||||
ATTR_DEVICES,
|
ATTR_DEVICES,
|
||||||
@@ -30,9 +39,12 @@ from ..const import (
|
|||||||
ATTR_HOST_IPC,
|
ATTR_HOST_IPC,
|
||||||
ATTR_HOST_NETWORK,
|
ATTR_HOST_NETWORK,
|
||||||
ATTR_HOST_PID,
|
ATTR_HOST_PID,
|
||||||
|
ATTR_HOST_UTS,
|
||||||
ATTR_IMAGE,
|
ATTR_IMAGE,
|
||||||
ATTR_INGRESS,
|
ATTR_INGRESS,
|
||||||
|
ATTR_INGRESS_STREAM,
|
||||||
ATTR_INIT,
|
ATTR_INIT,
|
||||||
|
ATTR_JOURNALD,
|
||||||
ATTR_KERNEL_MODULES,
|
ATTR_KERNEL_MODULES,
|
||||||
ATTR_LEGACY,
|
ATTR_LEGACY,
|
||||||
ATTR_LOCATON,
|
ATTR_LOCATON,
|
||||||
@@ -46,20 +58,24 @@ from ..const import (
|
|||||||
ATTR_PORTS,
|
ATTR_PORTS,
|
||||||
ATTR_PORTS_DESCRIPTION,
|
ATTR_PORTS_DESCRIPTION,
|
||||||
ATTR_PRIVILEGED,
|
ATTR_PRIVILEGED,
|
||||||
|
ATTR_REALTIME,
|
||||||
ATTR_REPOSITORY,
|
ATTR_REPOSITORY,
|
||||||
ATTR_SCHEMA,
|
ATTR_SCHEMA,
|
||||||
ATTR_SERVICES,
|
ATTR_SERVICES,
|
||||||
ATTR_SLUG,
|
ATTR_SLUG,
|
||||||
ATTR_SNAPSHOT_EXCLUDE,
|
|
||||||
ATTR_STAGE,
|
ATTR_STAGE,
|
||||||
ATTR_STARTUP,
|
ATTR_STARTUP,
|
||||||
ATTR_STDIN,
|
ATTR_STDIN,
|
||||||
ATTR_TIMEOUT,
|
ATTR_TIMEOUT,
|
||||||
ATTR_TMPFS,
|
ATTR_TMPFS,
|
||||||
|
ATTR_TRANSLATIONS,
|
||||||
|
ATTR_TYPE,
|
||||||
|
ATTR_UART,
|
||||||
ATTR_UDEV,
|
ATTR_UDEV,
|
||||||
ATTR_URL,
|
ATTR_URL,
|
||||||
ATTR_USB,
|
ATTR_USB,
|
||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
|
ATTR_VERSION_TIMESTAMP,
|
||||||
ATTR_VIDEO,
|
ATTR_VIDEO,
|
||||||
ATTR_WATCHDOG,
|
ATTR_WATCHDOG,
|
||||||
ATTR_WEBUI,
|
ATTR_WEBUI,
|
||||||
@@ -67,22 +83,47 @@ from ..const import (
|
|||||||
SECURITY_DISABLE,
|
SECURITY_DISABLE,
|
||||||
SECURITY_PROFILE,
|
SECURITY_PROFILE,
|
||||||
AddonBoot,
|
AddonBoot,
|
||||||
|
AddonBootConfig,
|
||||||
AddonStage,
|
AddonStage,
|
||||||
AddonStartup,
|
AddonStartup,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys
|
||||||
from .validate import RE_SERVICE, RE_VOLUME, schema_ui_options, validate_options
|
from ..docker.const import Capabilities
|
||||||
|
from ..exceptions import AddonsNotSupportedError
|
||||||
|
from ..jobs.const import JOB_GROUP_ADDON
|
||||||
|
from ..jobs.job_group import JobGroup
|
||||||
|
from ..utils import version_is_new_enough
|
||||||
|
from .configuration import FolderMapping
|
||||||
|
from .const import (
|
||||||
|
ATTR_BACKUP,
|
||||||
|
ATTR_BREAKING_VERSIONS,
|
||||||
|
ATTR_CODENOTARY,
|
||||||
|
ATTR_PATH,
|
||||||
|
ATTR_READ_ONLY,
|
||||||
|
AddonBackupMode,
|
||||||
|
MappingType,
|
||||||
|
)
|
||||||
|
from .options import AddonOptions, UiOptions
|
||||||
|
from .validate import RE_SERVICE
|
||||||
|
|
||||||
Data = Dict[str, Any]
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
Data = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class AddonModel(CoreSysAttributes, ABC):
|
class AddonModel(JobGroup, ABC):
|
||||||
"""Add-on Data layout."""
|
"""Add-on Data layout."""
|
||||||
|
|
||||||
def __init__(self, coresys: CoreSys, slug: str):
|
def __init__(self, coresys: CoreSys, slug: str):
|
||||||
"""Initialize data holder."""
|
"""Initialize data holder."""
|
||||||
self.coresys: CoreSys = coresys
|
super().__init__(
|
||||||
|
coresys, JOB_GROUP_ADDON.format_map(defaultdict(str, slug=slug)), slug
|
||||||
|
)
|
||||||
self.slug: str = slug
|
self.slug: str = slug
|
||||||
|
self._path_icon_exists: bool = False
|
||||||
|
self._path_logo_exists: bool = False
|
||||||
|
self._path_changelog_exists: bool = False
|
||||||
|
self._path_documentation_exists: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -105,17 +146,22 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self._available(self.data)
|
return self._available(self.data)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def options(self) -> Dict[str, Any]:
|
def options(self) -> dict[str, Any]:
|
||||||
"""Return options with local changes."""
|
"""Return options with local changes."""
|
||||||
return self.data[ATTR_OPTIONS]
|
return self.data[ATTR_OPTIONS]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def boot(self) -> AddonBoot:
|
def boot_config(self) -> AddonBootConfig:
|
||||||
"""Return boot config with prio local settings."""
|
"""Return boot config."""
|
||||||
return self.data[ATTR_BOOT]
|
return self.data[ATTR_BOOT]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auto_update(self) -> Optional[bool]:
|
def boot(self) -> AddonBoot:
|
||||||
|
"""Return boot config with prio local settings unless config is forced."""
|
||||||
|
return AddonBoot(self.data[ATTR_BOOT])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auto_update(self) -> bool | None:
|
||||||
"""Return if auto update is enable."""
|
"""Return if auto update is enable."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -130,7 +176,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.slug.replace("_", "-")
|
return self.slug.replace("_", "-")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dns(self) -> List[str]:
|
def dns(self) -> list[str]:
|
||||||
"""Return list of DNS name for that add-on."""
|
"""Return list of DNS name for that add-on."""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -140,22 +186,22 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.data[ATTR_TIMEOUT]
|
return self.data[ATTR_TIMEOUT]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uuid(self) -> Optional[str]:
|
def uuid(self) -> str | None:
|
||||||
"""Return an API token for this add-on."""
|
"""Return an API token for this add-on."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supervisor_token(self) -> Optional[str]:
|
def supervisor_token(self) -> str | None:
|
||||||
"""Return access token for Supervisor API."""
|
"""Return access token for Supervisor API."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ingress_token(self) -> Optional[str]:
|
def ingress_token(self) -> str | None:
|
||||||
"""Return access token for Supervisor API."""
|
"""Return access token for Supervisor API."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ingress_entry(self) -> Optional[str]:
|
def ingress_entry(self) -> str | None:
|
||||||
"""Return ingress external URL."""
|
"""Return ingress external URL."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -165,7 +211,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.data[ATTR_DESCRIPTON]
|
return self.data[ATTR_DESCRIPTON]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def long_description(self) -> Optional[str]:
|
def long_description(self) -> str | None:
|
||||||
"""Return README.md as long_description."""
|
"""Return README.md as long_description."""
|
||||||
readme = Path(self.path_location, "README.md")
|
readme = Path(self.path_location, "README.md")
|
||||||
|
|
||||||
@@ -174,8 +220,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Return data
|
# Return data
|
||||||
with readme.open("r") as readme_file:
|
return readme.read_text(encoding="utf-8")
|
||||||
return readme_file.read()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def repository(self) -> str:
|
def repository(self) -> str:
|
||||||
@@ -183,12 +228,22 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.data[ATTR_REPOSITORY]
|
return self.data[ATTR_REPOSITORY]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_version(self) -> str:
|
def translations(self) -> dict:
|
||||||
|
"""Return add-on translations."""
|
||||||
|
return self.data[ATTR_TRANSLATIONS]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_version(self) -> AwesomeVersion:
|
||||||
"""Return latest version of add-on."""
|
"""Return latest version of add-on."""
|
||||||
return self.data[ATTR_VERSION]
|
return self.data[ATTR_VERSION]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def version(self) -> Optional[str]:
|
def latest_version_timestamp(self) -> datetime:
|
||||||
|
"""Return when latest version was first seen."""
|
||||||
|
return utc_from_timestamp(self.data[ATTR_VERSION_TIMESTAMP])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self) -> AwesomeVersion:
|
||||||
"""Return version of add-on."""
|
"""Return version of add-on."""
|
||||||
return self.data[ATTR_VERSION]
|
return self.data[ATTR_VERSION]
|
||||||
|
|
||||||
@@ -213,7 +268,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.data[ATTR_STAGE]
|
return self.data[ATTR_STAGE]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def services_role(self) -> Dict[str, str]:
|
def services_role(self) -> dict[str, str]:
|
||||||
"""Return dict of services with rights."""
|
"""Return dict of services with rights."""
|
||||||
services_list = self.data.get(ATTR_SERVICES, [])
|
services_list = self.data.get(ATTR_SERVICES, [])
|
||||||
|
|
||||||
@@ -226,37 +281,37 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return services
|
return services
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def discovery(self) -> List[str]:
|
def discovery(self) -> list[str]:
|
||||||
"""Return list of discoverable components/platforms."""
|
"""Return list of discoverable components/platforms."""
|
||||||
return self.data.get(ATTR_DISCOVERY, [])
|
return self.data.get(ATTR_DISCOVERY, [])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ports_description(self) -> Optional[Dict[str, str]]:
|
def ports_description(self) -> dict[str, str] | None:
|
||||||
"""Return descriptions of ports."""
|
"""Return descriptions of ports."""
|
||||||
return self.data.get(ATTR_PORTS_DESCRIPTION)
|
return self.data.get(ATTR_PORTS_DESCRIPTION)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ports(self) -> Optional[Dict[str, Optional[int]]]:
|
def ports(self) -> dict[str, int | None] | None:
|
||||||
"""Return ports of add-on."""
|
"""Return ports of add-on."""
|
||||||
return self.data.get(ATTR_PORTS)
|
return self.data.get(ATTR_PORTS)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ingress_url(self) -> Optional[str]:
|
def ingress_url(self) -> str | None:
|
||||||
"""Return URL to ingress url."""
|
"""Return URL to ingress url."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def webui(self) -> Optional[str]:
|
def webui(self) -> str | None:
|
||||||
"""Return URL to webui or None."""
|
"""Return URL to webui or None."""
|
||||||
return self.data.get(ATTR_WEBUI)
|
return self.data.get(ATTR_WEBUI)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def watchdog(self) -> Optional[str]:
|
def watchdog(self) -> str | None:
|
||||||
"""Return URL to for watchdog or None."""
|
"""Return URL to for watchdog or None."""
|
||||||
return self.data.get(ATTR_WATCHDOG)
|
return self.data.get(ATTR_WATCHDOG)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ingress_port(self) -> Optional[int]:
|
def ingress_port(self) -> int | None:
|
||||||
"""Return Ingress port."""
|
"""Return Ingress port."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -290,28 +345,28 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
"""Return True if add-on run on host IPC namespace."""
|
"""Return True if add-on run on host IPC namespace."""
|
||||||
return self.data[ATTR_HOST_IPC]
|
return self.data[ATTR_HOST_IPC]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host_uts(self) -> bool:
|
||||||
|
"""Return True if add-on run on host UTS namespace."""
|
||||||
|
return self.data[ATTR_HOST_UTS]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def host_dbus(self) -> bool:
|
def host_dbus(self) -> bool:
|
||||||
"""Return True if add-on run on host D-BUS."""
|
"""Return True if add-on run on host D-BUS."""
|
||||||
return self.data[ATTR_HOST_DBUS]
|
return self.data[ATTR_HOST_DBUS]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def devices(self) -> List[str]:
|
def static_devices(self) -> list[Path]:
|
||||||
"""Return devices of add-on."""
|
"""Return static devices of add-on."""
|
||||||
return self.data.get(ATTR_DEVICES, [])
|
return [Path(node) for node in self.data.get(ATTR_DEVICES, [])]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tmpfs(self) -> Optional[str]:
|
def environment(self) -> dict[str, str] | None:
|
||||||
"""Return tmpfs of add-on."""
|
|
||||||
return self.data.get(ATTR_TMPFS)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def environment(self) -> Optional[Dict[str, str]]:
|
|
||||||
"""Return environment of add-on."""
|
"""Return environment of add-on."""
|
||||||
return self.data.get(ATTR_ENVIRONMENT)
|
return self.data.get(ATTR_ENVIRONMENT)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def privileged(self) -> List[str]:
|
def privileged(self) -> list[Capabilities]:
|
||||||
"""Return list of privilege."""
|
"""Return list of privilege."""
|
||||||
return self.data.get(ATTR_PRIVILEGED, [])
|
return self.data.get(ATTR_PRIVILEGED, [])
|
||||||
|
|
||||||
@@ -350,9 +405,24 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.data[ATTR_HASSIO_ROLE]
|
return self.data[ATTR_HASSIO_ROLE]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def snapshot_exclude(self) -> List[str]:
|
def backup_exclude(self) -> list[str]:
|
||||||
"""Return Exclude list for snapshot."""
|
"""Return Exclude list for backup."""
|
||||||
return self.data.get(ATTR_SNAPSHOT_EXCLUDE, [])
|
return self.data.get(ATTR_BACKUP_EXCLUDE, [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_pre(self) -> str | None:
|
||||||
|
"""Return pre-backup command."""
|
||||||
|
return self.data.get(ATTR_BACKUP_PRE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_post(self) -> str | None:
|
||||||
|
"""Return post-backup command."""
|
||||||
|
return self.data.get(ATTR_BACKUP_POST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_mode(self) -> AddonBackupMode:
|
||||||
|
"""Return if backup is hot/cold."""
|
||||||
|
return self.data[ATTR_BACKUP]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_init(self) -> bool:
|
def default_init(self) -> bool:
|
||||||
@@ -370,10 +440,15 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.data[ATTR_INGRESS]
|
return self.data[ATTR_INGRESS]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ingress_panel(self) -> Optional[bool]:
|
def ingress_panel(self) -> bool | None:
|
||||||
"""Return True if the add-on access support ingress."""
|
"""Return True if the add-on access support ingress."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ingress_stream(self) -> bool:
|
||||||
|
"""Return True if post requests to ingress should be streamed."""
|
||||||
|
return self.data[ATTR_INGRESS_STREAM]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_gpio(self) -> bool:
|
def with_gpio(self) -> bool:
|
||||||
"""Return True if the add-on access to GPIO interface."""
|
"""Return True if the add-on access to GPIO interface."""
|
||||||
@@ -387,7 +462,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
@property
|
@property
|
||||||
def with_uart(self) -> bool:
|
def with_uart(self) -> bool:
|
||||||
"""Return True if we should map all UART device."""
|
"""Return True if we should map all UART device."""
|
||||||
return self.data[ATTR_AUTO_UART]
|
return self.data[ATTR_UART]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_udev(self) -> bool:
|
def with_udev(self) -> bool:
|
||||||
@@ -399,6 +474,11 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
"""Return True if the add-on access to kernel modules."""
|
"""Return True if the add-on access to kernel modules."""
|
||||||
return self.data[ATTR_KERNEL_MODULES]
|
return self.data[ATTR_KERNEL_MODULES]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def with_realtime(self) -> bool:
|
||||||
|
"""Return True if the add-on need realtime schedule functions."""
|
||||||
|
return self.data[ATTR_REALTIME]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_full_access(self) -> bool:
|
def with_full_access(self) -> bool:
|
||||||
"""Return True if the add-on want full access to hardware."""
|
"""Return True if the add-on want full access to hardware."""
|
||||||
@@ -409,6 +489,11 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
"""Return True if the add-on read access to devicetree."""
|
"""Return True if the add-on read access to devicetree."""
|
||||||
return self.data[ATTR_DEVICETREE]
|
return self.data[ATTR_DEVICETREE]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def with_tmpfs(self) -> str | None:
|
||||||
|
"""Return if tmp is in memory of add-on."""
|
||||||
|
return self.data[ATTR_TMPFS]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def access_auth_api(self) -> bool:
|
def access_auth_api(self) -> bool:
|
||||||
"""Return True if the add-on access to login/auth backend."""
|
"""Return True if the add-on access to login/auth backend."""
|
||||||
@@ -425,47 +510,55 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.data[ATTR_VIDEO]
|
return self.data[ATTR_VIDEO]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def homeassistant_version(self) -> Optional[str]:
|
def homeassistant_version(self) -> str | None:
|
||||||
"""Return min Home Assistant version they needed by Add-on."""
|
"""Return min Home Assistant version they needed by Add-on."""
|
||||||
return self.data.get(ATTR_HOMEASSISTANT)
|
return self.data.get(ATTR_HOMEASSISTANT)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self) -> Optional[str]:
|
def url(self) -> str | None:
|
||||||
"""Return URL of add-on."""
|
"""Return URL of add-on."""
|
||||||
return self.data.get(ATTR_URL)
|
return self.data.get(ATTR_URL)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_icon(self) -> bool:
|
def with_icon(self) -> bool:
|
||||||
"""Return True if an icon exists."""
|
"""Return True if an icon exists."""
|
||||||
return self.path_icon.exists()
|
return self._path_icon_exists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_logo(self) -> bool:
|
def with_logo(self) -> bool:
|
||||||
"""Return True if a logo exists."""
|
"""Return True if a logo exists."""
|
||||||
return self.path_logo.exists()
|
return self._path_logo_exists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_changelog(self) -> bool:
|
def with_changelog(self) -> bool:
|
||||||
"""Return True if a changelog exists."""
|
"""Return True if a changelog exists."""
|
||||||
return self.path_changelog.exists()
|
return self._path_changelog_exists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_documentation(self) -> bool:
|
def with_documentation(self) -> bool:
|
||||||
"""Return True if a documentation exists."""
|
"""Return True if a documentation exists."""
|
||||||
return self.path_documentation.exists()
|
return self._path_documentation_exists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_arch(self) -> List[str]:
|
def supported_arch(self) -> list[str]:
|
||||||
"""Return list of supported arch."""
|
"""Return list of supported arch."""
|
||||||
return self.data[ATTR_ARCH]
|
return self.data[ATTR_ARCH]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_machine(self) -> List[str]:
|
def supported_machine(self) -> list[str]:
|
||||||
"""Return list of supported machine."""
|
"""Return list of supported machine."""
|
||||||
return self.data.get(ATTR_MACHINE, [])
|
return self.data.get(ATTR_MACHINE, [])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def image(self) -> Optional[str]:
|
def arch(self) -> str:
|
||||||
|
"""Return architecture to use for the addon's image."""
|
||||||
|
if ATTR_IMAGE in self.data:
|
||||||
|
return self.sys_arch.match(self.data[ATTR_ARCH])
|
||||||
|
|
||||||
|
return self.sys_arch.default
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image(self) -> str | None:
|
||||||
"""Generate image name from data."""
|
"""Generate image name from data."""
|
||||||
return self._image(self.data)
|
return self._image(self.data)
|
||||||
|
|
||||||
@@ -475,14 +568,13 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return ATTR_IMAGE not in self.data
|
return ATTR_IMAGE not in self.data
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def map_volumes(self) -> Dict[str, str]:
|
def map_volumes(self) -> dict[MappingType, FolderMapping]:
|
||||||
"""Return a dict of {volume: policy} from add-on."""
|
"""Return a dict of {MappingType: FolderMapping} from add-on."""
|
||||||
volumes = {}
|
volumes = {}
|
||||||
for volume in self.data[ATTR_MAP]:
|
for volume in self.data[ATTR_MAP]:
|
||||||
result = RE_VOLUME.match(volume)
|
volumes[MappingType(volume[ATTR_TYPE])] = FolderMapping(
|
||||||
if not result:
|
volume.get(ATTR_PATH), volume[ATTR_READ_ONLY]
|
||||||
continue
|
)
|
||||||
volumes[result.group(1)] = result.group(2) or "ro"
|
|
||||||
|
|
||||||
return volumes
|
return volumes
|
||||||
|
|
||||||
@@ -517,22 +609,57 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return Path(self.path_location, "apparmor.txt")
|
return Path(self.path_location, "apparmor.txt")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def schema(self) -> vol.Schema:
|
def schema(self) -> AddonOptions:
|
||||||
"""Create a schema for add-on options."""
|
"""Return Addon options validation object."""
|
||||||
raw_schema = self.data[ATTR_SCHEMA]
|
raw_schema = self.data[ATTR_SCHEMA]
|
||||||
|
|
||||||
if isinstance(raw_schema, bool):
|
if isinstance(raw_schema, bool):
|
||||||
return vol.Schema(dict)
|
raw_schema = {}
|
||||||
return vol.Schema(vol.All(dict, validate_options(self.coresys, raw_schema)))
|
|
||||||
|
return AddonOptions(self.coresys, raw_schema, self.name, self.slug)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def schema_ui(self) -> Optional[List[Dict[str, Any]]]:
|
def schema_ui(self) -> list[dict[any, any]] | None:
|
||||||
"""Create a UI schema for add-on options."""
|
"""Create a UI schema for add-on options."""
|
||||||
raw_schema = self.data[ATTR_SCHEMA]
|
raw_schema = self.data[ATTR_SCHEMA]
|
||||||
|
|
||||||
if isinstance(raw_schema, bool):
|
if isinstance(raw_schema, bool):
|
||||||
return None
|
return None
|
||||||
return schema_ui_options(raw_schema)
|
return UiOptions(self.coresys)(raw_schema)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def with_journald(self) -> bool:
|
||||||
|
"""Return True if the add-on accesses the system journal."""
|
||||||
|
return self.data[ATTR_JOURNALD]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signed(self) -> bool:
|
||||||
|
"""Return True if the image is signed."""
|
||||||
|
return ATTR_CODENOTARY in self.data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def codenotary(self) -> str | None:
|
||||||
|
"""Return Signer email address for CAS."""
|
||||||
|
return self.data.get(ATTR_CODENOTARY)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def breaking_versions(self) -> list[AwesomeVersion]:
|
||||||
|
"""Return breaking versions of addon."""
|
||||||
|
return self.data[ATTR_BREAKING_VERSIONS]
|
||||||
|
|
||||||
|
def refresh_path_cache(self) -> Awaitable[None]:
|
||||||
|
"""Refresh cache of existing paths."""
|
||||||
|
|
||||||
|
def check_paths():
|
||||||
|
self._path_icon_exists = self.path_icon.exists()
|
||||||
|
self._path_logo_exists = self.path_logo.exists()
|
||||||
|
self._path_changelog_exists = self.path_changelog.exists()
|
||||||
|
self._path_documentation_exists = self.path_documentation.exists()
|
||||||
|
|
||||||
|
return self.sys_run_in_executor(check_paths)
|
||||||
|
|
||||||
|
def validate_availability(self) -> None:
|
||||||
|
"""Validate if addon is available for current system."""
|
||||||
|
return self._validate_availability(self.data, logger=_LOGGER.error)
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
"""Compaired add-on objects."""
|
"""Compaired add-on objects."""
|
||||||
@@ -540,29 +667,45 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return False
|
return False
|
||||||
return self.slug == other.slug
|
return self.slug == other.slug
|
||||||
|
|
||||||
def _available(self, config) -> bool:
|
def _validate_availability(
|
||||||
"""Return True if this add-on is available on this platform."""
|
self, config, *, logger: Callable[..., None] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Validate if addon is available for current system."""
|
||||||
# Architecture
|
# Architecture
|
||||||
if not self.sys_arch.is_supported(config[ATTR_ARCH]):
|
if not self.sys_arch.is_supported(config[ATTR_ARCH]):
|
||||||
return False
|
raise AddonsNotSupportedError(
|
||||||
|
f"Add-on {self.slug} not supported on this platform, supported architectures: {', '.join(config[ATTR_ARCH])}",
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
# Machine / Hardware
|
# Machine / Hardware
|
||||||
machine = config.get(ATTR_MACHINE)
|
machine = config.get(ATTR_MACHINE)
|
||||||
if machine and f"!{self.sys_machine}" in machine:
|
if machine and (
|
||||||
return False
|
f"!{self.sys_machine}" in machine or self.sys_machine not in machine
|
||||||
elif machine and self.sys_machine not in machine:
|
):
|
||||||
return False
|
raise AddonsNotSupportedError(
|
||||||
|
f"Add-on {self.slug} not supported on this machine, supported machine types: {', '.join(machine)}",
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
# Home Assistant
|
# Home Assistant
|
||||||
version = config.get(ATTR_HOMEASSISTANT)
|
version: AwesomeVersion | None = config.get(ATTR_HOMEASSISTANT)
|
||||||
if version is None or self.sys_homeassistant.version is None:
|
with suppress(AwesomeVersionException, TypeError):
|
||||||
return True
|
if version and not version_is_new_enough(
|
||||||
|
self.sys_homeassistant.version, version
|
||||||
|
):
|
||||||
|
raise AddonsNotSupportedError(
|
||||||
|
f"Add-on {self.slug} not supported on this system, requires Home Assistant version {version} or greater",
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _available(self, config) -> bool:
|
||||||
|
"""Return True if this add-on is available on this platform."""
|
||||||
try:
|
try:
|
||||||
return pkg_version.parse(
|
self._validate_availability(config)
|
||||||
self.sys_homeassistant.version
|
except AddonsNotSupportedError:
|
||||||
) >= pkg_version.parse(version)
|
return False
|
||||||
except pkg_version.InvalidVersion:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _image(self, config) -> str:
|
def _image(self, config) -> str:
|
||||||
@@ -574,19 +717,3 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
|
|
||||||
# local build
|
# local build
|
||||||
return f"{config[ATTR_REPOSITORY]}/{self.sys_arch.default}-addon-{config[ATTR_SLUG]}"
|
return f"{config[ATTR_REPOSITORY]}/{self.sys_arch.default}-addon-{config[ATTR_SLUG]}"
|
||||||
|
|
||||||
def install(self) -> Awaitable[None]:
|
|
||||||
"""Install this add-on."""
|
|
||||||
return self.sys_addons.install(self.slug)
|
|
||||||
|
|
||||||
def uninstall(self) -> Awaitable[None]:
|
|
||||||
"""Uninstall this add-on."""
|
|
||||||
return self.sys_addons.uninstall(self.slug)
|
|
||||||
|
|
||||||
def update(self) -> Awaitable[None]:
|
|
||||||
"""Update this add-on."""
|
|
||||||
return self.sys_addons.update(self.slug)
|
|
||||||
|
|
||||||
def rebuild(self) -> Awaitable[None]:
|
|
||||||
"""Rebuild this add-on."""
|
|
||||||
return self.sys_addons.rebuild(self.slug)
|
|
||||||
|
|||||||
423
supervisor/addons/options.py
Normal file
423
supervisor/addons/options.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
"""Add-on Options / UI rendering."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
|
from ..exceptions import HardwareNotFound
|
||||||
|
from ..hardware.const import UdevSubsystem
|
||||||
|
from ..hardware.data import Device
|
||||||
|
from ..validate import network_port
|
||||||
|
|
||||||
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_STR = "str"
|
||||||
|
_INT = "int"
|
||||||
|
_FLOAT = "float"
|
||||||
|
_BOOL = "bool"
|
||||||
|
_PASSWORD = "password"
|
||||||
|
_EMAIL = "email"
|
||||||
|
_URL = "url"
|
||||||
|
_PORT = "port"
|
||||||
|
_MATCH = "match"
|
||||||
|
_LIST = "list"
|
||||||
|
_DEVICE = "device"
|
||||||
|
|
||||||
|
RE_SCHEMA_ELEMENT = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|bool"
|
||||||
|
r"|email"
|
||||||
|
r"|url"
|
||||||
|
r"|port"
|
||||||
|
r"|device(?:\((?P<filter>subsystem=[a-z]+)\))?"
|
||||||
|
r"|str(?:\((?P<s_min>\d+)?,(?P<s_max>\d+)?\))?"
|
||||||
|
r"|password(?:\((?P<p_min>\d+)?,(?P<p_max>\d+)?\))?"
|
||||||
|
r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?"
|
||||||
|
r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?"
|
||||||
|
r"|match\((?P<match>.*)\)"
|
||||||
|
r"|list\((?P<list>.+)\)"
|
||||||
|
r")\??$"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SCHEMA_LENGTH_PARTS = (
|
||||||
|
"i_min",
|
||||||
|
"i_max",
|
||||||
|
"f_min",
|
||||||
|
"f_max",
|
||||||
|
"s_min",
|
||||||
|
"s_max",
|
||||||
|
"p_min",
|
||||||
|
"p_max",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AddonOptions(CoreSysAttributes):
|
||||||
|
"""Validate Add-ons Options."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, coresys: CoreSys, raw_schema: dict[str, Any], name: str, slug: str
|
||||||
|
):
|
||||||
|
"""Validate schema."""
|
||||||
|
self.coresys: CoreSys = coresys
|
||||||
|
self.raw_schema: dict[str, Any] = raw_schema
|
||||||
|
self.devices: set[Device] = set()
|
||||||
|
self.pwned: set[str] = set()
|
||||||
|
self._name = name
|
||||||
|
self._slug = slug
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validate(self) -> vol.Schema:
|
||||||
|
"""Create a schema for add-on options."""
|
||||||
|
return vol.Schema(vol.All(dict, self))
|
||||||
|
|
||||||
|
def __call__(self, struct):
|
||||||
|
"""Create schema validator for add-ons options."""
|
||||||
|
options = {}
|
||||||
|
|
||||||
|
# read options
|
||||||
|
for key, value in struct.items():
|
||||||
|
# Ignore unknown options / remove from list
|
||||||
|
if key not in self.raw_schema:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Option '%s' does not exist in the schema for %s (%s)",
|
||||||
|
key,
|
||||||
|
self._name,
|
||||||
|
self._slug,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
typ = self.raw_schema[key]
|
||||||
|
try:
|
||||||
|
if isinstance(typ, list):
|
||||||
|
# nested value list
|
||||||
|
options[key] = self._nested_validate_list(typ[0], value, key)
|
||||||
|
elif isinstance(typ, dict):
|
||||||
|
# nested value dict
|
||||||
|
options[key] = self._nested_validate_dict(typ, value, key)
|
||||||
|
else:
|
||||||
|
# normal value
|
||||||
|
options[key] = self._single_validate(typ, value, key)
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Type error for option '{key}' in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
self._check_missing_options(self.raw_schema, options, "root")
|
||||||
|
return options
|
||||||
|
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
|
def _single_validate(self, typ: str, value: Any, key: str):
|
||||||
|
"""Validate a single element."""
|
||||||
|
# if required argument
|
||||||
|
if value is None:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Missing required option '{key}' in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# Lookup secret
|
||||||
|
if str(value).startswith("!secret "):
|
||||||
|
secret: str = value.partition(" ")[2]
|
||||||
|
value = self.sys_homeassistant.secrets.get(secret)
|
||||||
|
if value is None:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Unknown secret '{secret}' in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# parse extend data from type
|
||||||
|
match = RE_SCHEMA_ELEMENT.match(typ)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Unknown type '{typ}' in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# prepare range
|
||||||
|
range_args = {}
|
||||||
|
for group_name in _SCHEMA_LENGTH_PARTS:
|
||||||
|
group_value = match.group(group_name)
|
||||||
|
if group_value:
|
||||||
|
range_args[group_name[2:]] = float(group_value)
|
||||||
|
|
||||||
|
if typ.startswith(_STR) or typ.startswith(_PASSWORD):
|
||||||
|
if typ.startswith(_PASSWORD) and value:
|
||||||
|
self.pwned.add(hashlib.sha1(str(value).encode()).hexdigest())
|
||||||
|
return vol.All(str(value), vol.Range(**range_args))(value)
|
||||||
|
elif typ.startswith(_INT):
|
||||||
|
return vol.All(vol.Coerce(int), vol.Range(**range_args))(value)
|
||||||
|
elif typ.startswith(_FLOAT):
|
||||||
|
return vol.All(vol.Coerce(float), vol.Range(**range_args))(value)
|
||||||
|
elif typ.startswith(_BOOL):
|
||||||
|
return vol.Boolean()(value)
|
||||||
|
elif typ.startswith(_EMAIL):
|
||||||
|
return vol.Email()(value)
|
||||||
|
elif typ.startswith(_URL):
|
||||||
|
return vol.Url()(value)
|
||||||
|
elif typ.startswith(_PORT):
|
||||||
|
return network_port(value)
|
||||||
|
elif typ.startswith(_MATCH):
|
||||||
|
return vol.Match(match.group("match"))(str(value))
|
||||||
|
elif typ.startswith(_LIST):
|
||||||
|
return vol.In(match.group("list").split("|"))(str(value))
|
||||||
|
elif typ.startswith(_DEVICE):
|
||||||
|
try:
|
||||||
|
device = self.sys_hardware.get_by_path(Path(value))
|
||||||
|
except HardwareNotFound:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Device '{value}' does not exist in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# Have filter
|
||||||
|
if match.group("filter"):
|
||||||
|
str_filter = match.group("filter")
|
||||||
|
device_filter = _create_device_filter(str_filter)
|
||||||
|
if device not in self.sys_hardware.filter_devices(**device_filter):
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Device '{value}' don't match the filter {str_filter}! in {self._name} ({self._slug})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Device valid
|
||||||
|
self.devices.add(device)
|
||||||
|
return str(device.path)
|
||||||
|
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Fatal error for option '{key}' with type '{typ}' in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def _nested_validate_list(self, typ: Any, data_list: list[Any], key: str):
|
||||||
|
"""Validate nested items."""
|
||||||
|
options = []
|
||||||
|
|
||||||
|
# Make sure it is a list
|
||||||
|
if not isinstance(data_list, list):
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Invalid list for option '{key}' in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# Process list
|
||||||
|
for element in data_list:
|
||||||
|
# Nested?
|
||||||
|
if isinstance(typ, dict):
|
||||||
|
c_options = self._nested_validate_dict(typ, element, key)
|
||||||
|
options.append(c_options)
|
||||||
|
else:
|
||||||
|
options.append(self._single_validate(typ, element, key))
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
def _nested_validate_dict(
|
||||||
|
self, typ: dict[Any, Any], data_dict: dict[Any, Any], key: str
|
||||||
|
):
|
||||||
|
"""Validate nested items."""
|
||||||
|
options = {}
|
||||||
|
|
||||||
|
# Make sure it is a dict
|
||||||
|
if not isinstance(data_dict, dict):
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Invalid dict for option '{key}' in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# Process dict
|
||||||
|
for c_key, c_value in data_dict.items():
|
||||||
|
# Ignore unknown options / remove from list
|
||||||
|
if c_key not in typ:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Unknown option '%s' for %s (%s)", c_key, self._name, self._slug
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Nested?
|
||||||
|
if isinstance(typ[c_key], list):
|
||||||
|
options[c_key] = self._nested_validate_list(
|
||||||
|
typ[c_key][0], c_value, c_key
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
options[c_key] = self._single_validate(typ[c_key], c_value, c_key)
|
||||||
|
|
||||||
|
self._check_missing_options(typ, options, key)
|
||||||
|
return options
|
||||||
|
|
||||||
|
def _check_missing_options(
|
||||||
|
self, origin: dict[Any, Any], exists: dict[Any, Any], root: str
|
||||||
|
) -> None:
|
||||||
|
"""Check if all options are exists."""
|
||||||
|
missing = set(origin) - set(exists)
|
||||||
|
for miss_opt in missing:
|
||||||
|
miss_schema = origin[miss_opt]
|
||||||
|
|
||||||
|
# If its a list then value in list decides if its optional like ["str?"]
|
||||||
|
if isinstance(miss_schema, list) and len(miss_schema) > 0:
|
||||||
|
miss_schema = miss_schema[0]
|
||||||
|
|
||||||
|
if isinstance(miss_schema, str) and miss_schema.endswith("?"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"Missing option '{miss_opt}' in {root} in {self._name} ({self._slug})"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
class UiOptions(CoreSysAttributes):
|
||||||
|
"""Render UI Add-ons Options."""
|
||||||
|
|
||||||
|
def __init__(self, coresys: CoreSys) -> None:
|
||||||
|
"""Initialize UI option render."""
|
||||||
|
self.coresys = coresys
|
||||||
|
|
||||||
|
def __call__(self, raw_schema: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Generate UI schema."""
|
||||||
|
ui_schema: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# read options
|
||||||
|
for key, value in raw_schema.items():
|
||||||
|
if isinstance(value, list):
|
||||||
|
# nested value list
|
||||||
|
self._nested_ui_list(ui_schema, value, key)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
# nested value dict
|
||||||
|
self._nested_ui_dict(ui_schema, value, key)
|
||||||
|
else:
|
||||||
|
# normal value
|
||||||
|
self._single_ui_option(ui_schema, value, key)
|
||||||
|
|
||||||
|
return ui_schema
|
||||||
|
|
||||||
|
def _single_ui_option(
|
||||||
|
self,
|
||||||
|
ui_schema: list[dict[str, Any]],
|
||||||
|
value: str,
|
||||||
|
key: str,
|
||||||
|
multiple: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Validate a single element."""
|
||||||
|
ui_node: dict[str, str | bool | float | list[str]] = {"name": key}
|
||||||
|
|
||||||
|
# If multiple
|
||||||
|
if multiple:
|
||||||
|
ui_node["multiple"] = True
|
||||||
|
|
||||||
|
# Parse extend data from type
|
||||||
|
match = RE_SCHEMA_ELEMENT.match(value)
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prepare range
|
||||||
|
for group_name in _SCHEMA_LENGTH_PARTS:
|
||||||
|
group_value = match.group(group_name)
|
||||||
|
if not group_value:
|
||||||
|
continue
|
||||||
|
if group_name[2:] == "min":
|
||||||
|
ui_node["lengthMin"] = float(group_value)
|
||||||
|
elif group_name[2:] == "max":
|
||||||
|
ui_node["lengthMax"] = float(group_value)
|
||||||
|
|
||||||
|
# If required
|
||||||
|
if value.endswith("?"):
|
||||||
|
ui_node["optional"] = True
|
||||||
|
else:
|
||||||
|
ui_node["required"] = True
|
||||||
|
|
||||||
|
# Data types
|
||||||
|
if value.startswith(_STR):
|
||||||
|
ui_node["type"] = "string"
|
||||||
|
elif value.startswith(_PASSWORD):
|
||||||
|
ui_node["type"] = "string"
|
||||||
|
ui_node["format"] = "password"
|
||||||
|
elif value.startswith(_INT):
|
||||||
|
ui_node["type"] = "integer"
|
||||||
|
elif value.startswith(_FLOAT):
|
||||||
|
ui_node["type"] = "float"
|
||||||
|
elif value.startswith(_BOOL):
|
||||||
|
ui_node["type"] = "boolean"
|
||||||
|
elif value.startswith(_EMAIL):
|
||||||
|
ui_node["type"] = "string"
|
||||||
|
ui_node["format"] = "email"
|
||||||
|
elif value.startswith(_URL):
|
||||||
|
ui_node["type"] = "string"
|
||||||
|
ui_node["format"] = "url"
|
||||||
|
elif value.startswith(_PORT):
|
||||||
|
ui_node["type"] = "integer"
|
||||||
|
elif value.startswith(_MATCH):
|
||||||
|
ui_node["type"] = "string"
|
||||||
|
elif value.startswith(_LIST):
|
||||||
|
ui_node["type"] = "select"
|
||||||
|
ui_node["options"] = match.group("list").split("|")
|
||||||
|
elif value.startswith(_DEVICE):
|
||||||
|
ui_node["type"] = "select"
|
||||||
|
|
||||||
|
# Have filter
|
||||||
|
if match.group("filter"):
|
||||||
|
device_filter = _create_device_filter(match.group("filter"))
|
||||||
|
ui_node["options"] = [
|
||||||
|
(device.by_id or device.path).as_posix()
|
||||||
|
for device in self.sys_hardware.filter_devices(**device_filter)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
ui_node["options"] = [
|
||||||
|
(device.by_id or device.path).as_posix()
|
||||||
|
for device in self.sys_hardware.devices
|
||||||
|
]
|
||||||
|
|
||||||
|
ui_schema.append(ui_node)
|
||||||
|
|
||||||
|
def _nested_ui_list(
|
||||||
|
self,
|
||||||
|
ui_schema: list[dict[str, Any]],
|
||||||
|
option_list: list[Any],
|
||||||
|
key: str,
|
||||||
|
) -> None:
|
||||||
|
"""UI nested list items."""
|
||||||
|
try:
|
||||||
|
element = option_list[0]
|
||||||
|
except IndexError:
|
||||||
|
_LOGGER.error("Invalid schema %s", key)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(element, dict):
|
||||||
|
self._nested_ui_dict(ui_schema, element, key, multiple=True)
|
||||||
|
else:
|
||||||
|
self._single_ui_option(ui_schema, element, key, multiple=True)
|
||||||
|
|
||||||
|
def _nested_ui_dict(
|
||||||
|
self,
|
||||||
|
ui_schema: list[dict[str, Any]],
|
||||||
|
option_dict: dict[str, Any],
|
||||||
|
key: str,
|
||||||
|
multiple: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""UI nested dict items."""
|
||||||
|
ui_node = {
|
||||||
|
"name": key,
|
||||||
|
"type": "schema",
|
||||||
|
"optional": True,
|
||||||
|
"multiple": multiple,
|
||||||
|
}
|
||||||
|
|
||||||
|
nested_schema = []
|
||||||
|
for c_key, c_value in option_dict.items():
|
||||||
|
# Nested?
|
||||||
|
if isinstance(c_value, list):
|
||||||
|
self._nested_ui_list(nested_schema, c_value, c_key)
|
||||||
|
else:
|
||||||
|
self._single_ui_option(nested_schema, c_value, c_key)
|
||||||
|
|
||||||
|
ui_node["schema"] = nested_schema
|
||||||
|
ui_schema.append(ui_node)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_device_filter(str_filter: str) -> dict[str, Any]:
|
||||||
|
"""Generate device Filter."""
|
||||||
|
raw_filter = dict(value.split("=") for value in str_filter.split(";"))
|
||||||
|
|
||||||
|
clean_filter = {}
|
||||||
|
for key, value in raw_filter.items():
|
||||||
|
if key == "subsystem":
|
||||||
|
clean_filter[key] = UdevSubsystem(value)
|
||||||
|
else:
|
||||||
|
clean_filter[key] = value
|
||||||
|
|
||||||
|
return clean_filter
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Util add-ons functions."""
|
"""Util add-ons functions."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -6,18 +7,8 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ..const import (
|
from ..const import ROLE_ADMIN, ROLE_MANAGER, SECURITY_DISABLE, SECURITY_PROFILE
|
||||||
PRIVILEGED_DAC_READ_SEARCH,
|
from ..docker.const import Capabilities
|
||||||
PRIVILEGED_NET_ADMIN,
|
|
||||||
PRIVILEGED_SYS_ADMIN,
|
|
||||||
PRIVILEGED_SYS_MODULE,
|
|
||||||
PRIVILEGED_SYS_PTRACE,
|
|
||||||
PRIVILEGED_SYS_RAWIO,
|
|
||||||
ROLE_ADMIN,
|
|
||||||
ROLE_MANAGER,
|
|
||||||
SECURITY_DISABLE,
|
|
||||||
SECURITY_PROFILE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .model import AddonModel
|
from .model import AddonModel
|
||||||
@@ -26,10 +17,10 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def rating_security(addon: AddonModel) -> int:
|
def rating_security(addon: AddonModel) -> int:
|
||||||
"""Return 1-6 for security rating.
|
"""Return 1-8 for security rating.
|
||||||
|
|
||||||
1 = not secure
|
1 = not secure
|
||||||
6 = high secure
|
8 = high secure
|
||||||
"""
|
"""
|
||||||
rating = 5
|
rating = 5
|
||||||
|
|
||||||
@@ -45,17 +36,27 @@ def rating_security(addon: AddonModel) -> int:
|
|||||||
elif addon.access_auth_api:
|
elif addon.access_auth_api:
|
||||||
rating += 1
|
rating += 1
|
||||||
|
|
||||||
|
# Signed
|
||||||
|
if addon.signed:
|
||||||
|
rating += 1
|
||||||
|
|
||||||
# Privileged options
|
# Privileged options
|
||||||
if any(
|
if (
|
||||||
|
any(
|
||||||
privilege in addon.privileged
|
privilege in addon.privileged
|
||||||
for privilege in (
|
for privilege in (
|
||||||
PRIVILEGED_NET_ADMIN,
|
Capabilities.BPF,
|
||||||
PRIVILEGED_SYS_ADMIN,
|
Capabilities.DAC_READ_SEARCH,
|
||||||
PRIVILEGED_SYS_RAWIO,
|
Capabilities.NET_ADMIN,
|
||||||
PRIVILEGED_SYS_PTRACE,
|
Capabilities.NET_RAW,
|
||||||
PRIVILEGED_SYS_MODULE,
|
Capabilities.PERFMON,
|
||||||
PRIVILEGED_DAC_READ_SEARCH,
|
Capabilities.SYS_ADMIN,
|
||||||
|
Capabilities.SYS_MODULE,
|
||||||
|
Capabilities.SYS_PTRACE,
|
||||||
|
Capabilities.SYS_RAWIO,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
or addon.with_kernel_modules
|
||||||
):
|
):
|
||||||
rating += -1
|
rating += -1
|
||||||
|
|
||||||
@@ -73,15 +74,15 @@ def rating_security(addon: AddonModel) -> int:
|
|||||||
if addon.host_pid:
|
if addon.host_pid:
|
||||||
rating += -2
|
rating += -2
|
||||||
|
|
||||||
# Full Access
|
# UTS host namespace allows to set hostname only with SYS_ADMIN
|
||||||
if addon.with_full_access:
|
if addon.host_uts and Capabilities.SYS_ADMIN in addon.privileged:
|
||||||
rating += -2
|
rating += -1
|
||||||
|
|
||||||
# Docker Access
|
# Docker Access & full Access
|
||||||
if addon.access_docker_api:
|
if addon.access_docker_api or addon.with_full_access:
|
||||||
rating = 1
|
rating = 1
|
||||||
|
|
||||||
return max(min(6, rating), 1)
|
return max(min(8, rating), 1)
|
||||||
|
|
||||||
|
|
||||||
async def remove_data(folder: Path) -> None:
|
async def remove_data(folder: Path) -> None:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Validate add-ons options schema."""
|
"""Validate add-ons options schema."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Any, Dict, List, Union
|
from typing import Any
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -18,10 +19,13 @@ from ..const import (
|
|||||||
ATTR_AUDIO_INPUT,
|
ATTR_AUDIO_INPUT,
|
||||||
ATTR_AUDIO_OUTPUT,
|
ATTR_AUDIO_OUTPUT,
|
||||||
ATTR_AUTH_API,
|
ATTR_AUTH_API,
|
||||||
ATTR_AUTO_UART,
|
|
||||||
ATTR_AUTO_UPDATE,
|
ATTR_AUTO_UPDATE,
|
||||||
|
ATTR_BACKUP_EXCLUDE,
|
||||||
|
ATTR_BACKUP_POST,
|
||||||
|
ATTR_BACKUP_PRE,
|
||||||
ATTR_BOOT,
|
ATTR_BOOT,
|
||||||
ATTR_BUILD_FROM,
|
ATTR_BUILD_FROM,
|
||||||
|
ATTR_CONFIGURATION,
|
||||||
ATTR_DESCRIPTON,
|
ATTR_DESCRIPTON,
|
||||||
ATTR_DEVICES,
|
ATTR_DEVICES,
|
||||||
ATTR_DEVICETREE,
|
ATTR_DEVICETREE,
|
||||||
@@ -38,14 +42,18 @@ from ..const import (
|
|||||||
ATTR_HOST_IPC,
|
ATTR_HOST_IPC,
|
||||||
ATTR_HOST_NETWORK,
|
ATTR_HOST_NETWORK,
|
||||||
ATTR_HOST_PID,
|
ATTR_HOST_PID,
|
||||||
|
ATTR_HOST_UTS,
|
||||||
ATTR_IMAGE,
|
ATTR_IMAGE,
|
||||||
ATTR_INGRESS,
|
ATTR_INGRESS,
|
||||||
ATTR_INGRESS_ENTRY,
|
ATTR_INGRESS_ENTRY,
|
||||||
ATTR_INGRESS_PANEL,
|
ATTR_INGRESS_PANEL,
|
||||||
ATTR_INGRESS_PORT,
|
ATTR_INGRESS_PORT,
|
||||||
|
ATTR_INGRESS_STREAM,
|
||||||
ATTR_INGRESS_TOKEN,
|
ATTR_INGRESS_TOKEN,
|
||||||
ATTR_INIT,
|
ATTR_INIT,
|
||||||
|
ATTR_JOURNALD,
|
||||||
ATTR_KERNEL_MODULES,
|
ATTR_KERNEL_MODULES,
|
||||||
|
ATTR_LABELS,
|
||||||
ATTR_LEGACY,
|
ATTR_LEGACY,
|
||||||
ATTR_LOCATON,
|
ATTR_LOCATON,
|
||||||
ATTR_MACHINE,
|
ATTR_MACHINE,
|
||||||
@@ -60,19 +68,24 @@ from ..const import (
|
|||||||
ATTR_PORTS_DESCRIPTION,
|
ATTR_PORTS_DESCRIPTION,
|
||||||
ATTR_PRIVILEGED,
|
ATTR_PRIVILEGED,
|
||||||
ATTR_PROTECTED,
|
ATTR_PROTECTED,
|
||||||
|
ATTR_REALTIME,
|
||||||
ATTR_REPOSITORY,
|
ATTR_REPOSITORY,
|
||||||
ATTR_SCHEMA,
|
ATTR_SCHEMA,
|
||||||
ATTR_SERVICES,
|
ATTR_SERVICES,
|
||||||
ATTR_SLUG,
|
ATTR_SLUG,
|
||||||
ATTR_SNAPSHOT_EXCLUDE,
|
|
||||||
ATTR_SQUASH,
|
ATTR_SQUASH,
|
||||||
ATTR_STAGE,
|
ATTR_STAGE,
|
||||||
ATTR_STARTUP,
|
ATTR_STARTUP,
|
||||||
ATTR_STATE,
|
ATTR_STATE,
|
||||||
ATTR_STDIN,
|
ATTR_STDIN,
|
||||||
ATTR_SYSTEM,
|
ATTR_SYSTEM,
|
||||||
|
ATTR_SYSTEM_MANAGED,
|
||||||
|
ATTR_SYSTEM_MANAGED_CONFIG_ENTRY,
|
||||||
ATTR_TIMEOUT,
|
ATTR_TIMEOUT,
|
||||||
ATTR_TMPFS,
|
ATTR_TMPFS,
|
||||||
|
ATTR_TRANSLATIONS,
|
||||||
|
ATTR_TYPE,
|
||||||
|
ATTR_UART,
|
||||||
ATTR_UDEV,
|
ATTR_UDEV,
|
||||||
ATTR_URL,
|
ATTR_URL,
|
||||||
ATTR_USB,
|
ATTR_USB,
|
||||||
@@ -82,17 +95,17 @@ from ..const import (
|
|||||||
ATTR_VIDEO,
|
ATTR_VIDEO,
|
||||||
ATTR_WATCHDOG,
|
ATTR_WATCHDOG,
|
||||||
ATTR_WEBUI,
|
ATTR_WEBUI,
|
||||||
PRIVILEGED_ALL,
|
|
||||||
ROLE_ALL,
|
ROLE_ALL,
|
||||||
ROLE_DEFAULT,
|
ROLE_DEFAULT,
|
||||||
AddonBoot,
|
AddonBoot,
|
||||||
|
AddonBootConfig,
|
||||||
AddonStage,
|
AddonStage,
|
||||||
AddonStartup,
|
AddonStartup,
|
||||||
AddonState,
|
AddonState,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSys
|
from ..docker.const import Capabilities
|
||||||
from ..discovery.validate import valid_discovery_service
|
|
||||||
from ..validate import (
|
from ..validate import (
|
||||||
|
docker_image,
|
||||||
docker_ports,
|
docker_ports,
|
||||||
docker_ports_description,
|
docker_ports_description,
|
||||||
network_port,
|
network_port,
|
||||||
@@ -100,51 +113,26 @@ from ..validate import (
|
|||||||
uuid_match,
|
uuid_match,
|
||||||
version_tag,
|
version_tag,
|
||||||
)
|
)
|
||||||
|
from .const import (
|
||||||
|
ATTR_BACKUP,
|
||||||
|
ATTR_BREAKING_VERSIONS,
|
||||||
|
ATTR_CODENOTARY,
|
||||||
|
ATTR_PATH,
|
||||||
|
ATTR_READ_ONLY,
|
||||||
|
RE_SLUG,
|
||||||
|
AddonBackupMode,
|
||||||
|
MappingType,
|
||||||
|
)
|
||||||
|
from .options import RE_SCHEMA_ELEMENT
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RE_VOLUME = re.compile(
|
||||||
RE_VOLUME = re.compile(r"^(config|ssl|addons|backup|share|media)(?::(rw|ro))?$")
|
r"^(data|config|ssl|addons|backup|share|media|homeassistant_config|all_addon_configs|addon_config)(?::(rw|ro))?$"
|
||||||
|
)
|
||||||
RE_SERVICE = re.compile(r"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$")
|
RE_SERVICE = re.compile(r"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$")
|
||||||
|
|
||||||
V_STR = "str"
|
|
||||||
V_INT = "int"
|
|
||||||
V_FLOAT = "float"
|
|
||||||
V_BOOL = "bool"
|
|
||||||
V_PASSWORD = "password"
|
|
||||||
V_EMAIL = "email"
|
|
||||||
V_URL = "url"
|
|
||||||
V_PORT = "port"
|
|
||||||
V_MATCH = "match"
|
|
||||||
V_LIST = "list"
|
|
||||||
|
|
||||||
RE_SCHEMA_ELEMENT = re.compile(
|
|
||||||
r"^(?:"
|
|
||||||
r"|bool"
|
|
||||||
r"|email"
|
|
||||||
r"|url"
|
|
||||||
r"|port"
|
|
||||||
r"|str(?:\((?P<s_min>\d+)?,(?P<s_max>\d+)?\))?"
|
|
||||||
r"|password(?:\((?P<p_min>\d+)?,(?P<p_max>\d+)?\))?"
|
|
||||||
r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?"
|
|
||||||
r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?"
|
|
||||||
r"|match\((?P<match>.*)\)"
|
|
||||||
r"|list\((?P<list>.+)\)"
|
|
||||||
r")\??$"
|
|
||||||
)
|
|
||||||
|
|
||||||
_SCHEMA_LENGTH_PARTS = (
|
|
||||||
"i_min",
|
|
||||||
"i_max",
|
|
||||||
"f_min",
|
|
||||||
"f_max",
|
|
||||||
"s_min",
|
|
||||||
"s_max",
|
|
||||||
"p_min",
|
|
||||||
"p_max",
|
|
||||||
)
|
|
||||||
|
|
||||||
RE_DOCKER_IMAGE = re.compile(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$")
|
|
||||||
RE_DOCKER_IMAGE_BUILD = re.compile(
|
RE_DOCKER_IMAGE_BUILD = re.compile(
|
||||||
r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$"
|
r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$"
|
||||||
)
|
)
|
||||||
@@ -154,8 +142,10 @@ SCHEMA_ELEMENT = vol.Match(RE_SCHEMA_ELEMENT)
|
|||||||
RE_MACHINE = re.compile(
|
RE_MACHINE = re.compile(
|
||||||
r"^!?(?:"
|
r"^!?(?:"
|
||||||
r"|intel-nuc"
|
r"|intel-nuc"
|
||||||
|
r"|generic-x86-64"
|
||||||
r"|odroid-c2"
|
r"|odroid-c2"
|
||||||
r"|odroid-c4"
|
r"|odroid-c4"
|
||||||
|
r"|odroid-m1"
|
||||||
r"|odroid-n2"
|
r"|odroid-n2"
|
||||||
r"|odroid-xu"
|
r"|odroid-xu"
|
||||||
r"|qemuarm-64"
|
r"|qemuarm-64"
|
||||||
@@ -168,39 +158,180 @@ RE_MACHINE = re.compile(
|
|||||||
r"|raspberrypi3"
|
r"|raspberrypi3"
|
||||||
r"|raspberrypi4-64"
|
r"|raspberrypi4-64"
|
||||||
r"|raspberrypi4"
|
r"|raspberrypi4"
|
||||||
|
r"|raspberrypi5-64"
|
||||||
|
r"|yellow"
|
||||||
|
r"|green"
|
||||||
r"|tinker"
|
r"|tinker"
|
||||||
r")$"
|
r")$"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RE_SLUG_FIELD = re.compile(r"^" + RE_SLUG + r"$")
|
||||||
|
|
||||||
def _simple_startup(value) -> str:
|
|
||||||
"""Define startup schema."""
|
def _warn_addon_config(config: dict[str, Any]):
|
||||||
|
"""Warn about miss configs."""
|
||||||
|
name = config.get(ATTR_NAME)
|
||||||
|
if not name:
|
||||||
|
raise vol.Invalid("Invalid Add-on config!")
|
||||||
|
|
||||||
|
if config.get(ATTR_FULL_ACCESS, False) and (
|
||||||
|
config.get(ATTR_DEVICES)
|
||||||
|
or config.get(ATTR_UART)
|
||||||
|
or config.get(ATTR_USB)
|
||||||
|
or config.get(ATTR_GPIO)
|
||||||
|
):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on have full device access, and selective device access in the configuration. Please report this to the maintainer of %s",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.get(ATTR_BACKUP, AddonBackupMode.HOT) == AddonBackupMode.COLD and (
|
||||||
|
config.get(ATTR_BACKUP_POST) or config.get(ATTR_BACKUP_PRE)
|
||||||
|
):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on which only support COLD backups trying to use post/pre commands. Please report this to the maintainer of %s",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_addon_config(protocol=False):
|
||||||
|
"""Migrate addon config."""
|
||||||
|
|
||||||
|
def _migrate(config: dict[str, Any]):
|
||||||
|
name = config.get(ATTR_NAME)
|
||||||
|
if not name:
|
||||||
|
raise vol.Invalid("Invalid Add-on config!")
|
||||||
|
|
||||||
|
# Startup 2018-03-30
|
||||||
|
if config.get(ATTR_STARTUP) in ("before", "after"):
|
||||||
|
value = config[ATTR_STARTUP]
|
||||||
|
if protocol:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on config 'startup' with '%s' is deprecated. Please report this to the maintainer of %s",
|
||||||
|
value,
|
||||||
|
name,
|
||||||
|
)
|
||||||
if value == "before":
|
if value == "before":
|
||||||
return AddonStartup.SERVICES.value
|
config[ATTR_STARTUP] = AddonStartup.SERVICES
|
||||||
if value == "after":
|
elif value == "after":
|
||||||
return AddonStartup.APPLICATION.value
|
config[ATTR_STARTUP] = AddonStartup.APPLICATION
|
||||||
return value
|
|
||||||
|
# UART 2021-01-20
|
||||||
|
if "auto_uart" in config:
|
||||||
|
if protocol:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on config 'auto_uart' is deprecated, use 'uart'. Please report this to the maintainer of %s",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
config[ATTR_UART] = config.pop("auto_uart")
|
||||||
|
|
||||||
|
# Device 2021-01-20
|
||||||
|
if ATTR_DEVICES in config and any(":" in line for line in config[ATTR_DEVICES]):
|
||||||
|
if protocol:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on config 'devices' use a deprecated format, the new format uses a list of paths only. Please report this to the maintainer of %s",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
config[ATTR_DEVICES] = [line.split(":")[0] for line in config[ATTR_DEVICES]]
|
||||||
|
|
||||||
|
# TMPFS 2021-02-01
|
||||||
|
if ATTR_TMPFS in config and not isinstance(config[ATTR_TMPFS], bool):
|
||||||
|
if protocol:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on config 'tmpfs' use a deprecated format, new it's only a boolean. Please report this to the maintainer of %s",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
config[ATTR_TMPFS] = True
|
||||||
|
|
||||||
|
# 2021-06 "snapshot" renamed to "backup"
|
||||||
|
for entry in (
|
||||||
|
"snapshot_exclude",
|
||||||
|
"snapshot_post",
|
||||||
|
"snapshot_pre",
|
||||||
|
"snapshot",
|
||||||
|
):
|
||||||
|
if entry in config:
|
||||||
|
new_entry = entry.replace("snapshot", "backup")
|
||||||
|
config[new_entry] = config.pop(entry)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on config '%s' is deprecated, '%s' should be used instead. Please report this to the maintainer of %s",
|
||||||
|
entry,
|
||||||
|
new_entry,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2023-11 "map" entries can also be dict to allow path configuration
|
||||||
|
volumes = []
|
||||||
|
for entry in config.get(ATTR_MAP, []):
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
volumes.append(entry)
|
||||||
|
if isinstance(entry, str):
|
||||||
|
result = RE_VOLUME.match(entry)
|
||||||
|
if not result:
|
||||||
|
continue
|
||||||
|
volumes.append(
|
||||||
|
{
|
||||||
|
ATTR_TYPE: result.group(1),
|
||||||
|
ATTR_READ_ONLY: result.group(2) != "rw",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if volumes:
|
||||||
|
config[ATTR_MAP] = volumes
|
||||||
|
|
||||||
|
# 2023-10 "config" became "homeassistant" so /config can be used for addon's public config
|
||||||
|
if any(volume[ATTR_TYPE] == MappingType.CONFIG for volume in volumes):
|
||||||
|
if any(
|
||||||
|
volume
|
||||||
|
and volume[ATTR_TYPE]
|
||||||
|
in {MappingType.ADDON_CONFIG, MappingType.HOMEASSISTANT_CONFIG}
|
||||||
|
for volume in volumes
|
||||||
|
):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on config using incompatible map options, '%s' and '%s' are ignored if '%s' is included. Please report this to the maintainer of %s",
|
||||||
|
MappingType.ADDON_CONFIG,
|
||||||
|
MappingType.HOMEASSISTANT_CONFIG,
|
||||||
|
MappingType.CONFIG,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Add-on config using deprecated map option '%s' instead of '%s'. Please report this to the maintainer of %s",
|
||||||
|
MappingType.CONFIG,
|
||||||
|
MappingType.HOMEASSISTANT_CONFIG,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
return _migrate
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=no-value-for-parameter
|
# pylint: disable=no-value-for-parameter
|
||||||
SCHEMA_ADDON_CONFIG = vol.Schema(
|
_SCHEMA_ADDON_CONFIG = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_NAME): vol.Coerce(str),
|
vol.Required(ATTR_NAME): str,
|
||||||
vol.Required(ATTR_VERSION): vol.All(version_tag, str),
|
vol.Required(ATTR_VERSION): version_tag,
|
||||||
vol.Required(ATTR_SLUG): vol.Coerce(str),
|
vol.Required(ATTR_SLUG): vol.Match(RE_SLUG_FIELD),
|
||||||
vol.Required(ATTR_DESCRIPTON): vol.Coerce(str),
|
vol.Required(ATTR_DESCRIPTON): str,
|
||||||
vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)],
|
vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)],
|
||||||
vol.Optional(ATTR_MACHINE): vol.All([vol.Match(RE_MACHINE)], vol.Unique()),
|
vol.Optional(ATTR_MACHINE): vol.All([vol.Match(RE_MACHINE)], vol.Unique()),
|
||||||
vol.Optional(ATTR_URL): vol.Url(),
|
vol.Optional(ATTR_URL): vol.Url(),
|
||||||
vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.Coerce(AddonStartup)),
|
vol.Optional(ATTR_STARTUP, default=AddonStartup.APPLICATION): vol.Coerce(
|
||||||
vol.Required(ATTR_BOOT): vol.Coerce(AddonBoot),
|
AddonStartup
|
||||||
|
),
|
||||||
|
vol.Optional(ATTR_BOOT, default=AddonBootConfig.AUTO): vol.Coerce(
|
||||||
|
AddonBootConfig
|
||||||
|
),
|
||||||
vol.Optional(ATTR_INIT, default=True): vol.Boolean(),
|
vol.Optional(ATTR_INIT, default=True): vol.Boolean(),
|
||||||
vol.Optional(ATTR_ADVANCED, default=False): vol.Boolean(),
|
vol.Optional(ATTR_ADVANCED, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_STAGE, default=AddonStage.STABLE): vol.Coerce(AddonStage),
|
vol.Optional(ATTR_STAGE, default=AddonStage.STABLE): vol.Coerce(AddonStage),
|
||||||
vol.Optional(ATTR_PORTS): docker_ports,
|
vol.Optional(ATTR_PORTS): docker_ports,
|
||||||
vol.Optional(ATTR_PORTS_DESCRIPTION): docker_ports_description,
|
vol.Optional(ATTR_PORTS_DESCRIPTION): docker_ports_description,
|
||||||
vol.Optional(ATTR_WATCHDOG): vol.Match(
|
vol.Optional(ATTR_WATCHDOG): vol.Match(
|
||||||
r"^(?:https?|\[PROTO:\w+\]|tcp):\/\/\[HOST\]:\[PORT:\d+\].*$"
|
r"^(?:https?|\[PROTO:\w+\]|tcp):\/\/\[HOST\]:(\[PORT:\d+\]|\d+).*$"
|
||||||
),
|
),
|
||||||
vol.Optional(ATTR_WEBUI): vol.Match(
|
vol.Optional(ATTR_WEBUI): vol.Match(
|
||||||
r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$"
|
r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$"
|
||||||
@@ -209,30 +340,41 @@ SCHEMA_ADDON_CONFIG = vol.Schema(
|
|||||||
vol.Optional(ATTR_INGRESS_PORT, default=8099): vol.Any(
|
vol.Optional(ATTR_INGRESS_PORT, default=8099): vol.Any(
|
||||||
network_port, vol.Equal(0)
|
network_port, vol.Equal(0)
|
||||||
),
|
),
|
||||||
vol.Optional(ATTR_INGRESS_ENTRY): vol.Coerce(str),
|
vol.Optional(ATTR_INGRESS_ENTRY): str,
|
||||||
vol.Optional(ATTR_PANEL_ICON, default="mdi:puzzle"): vol.Coerce(str),
|
vol.Optional(ATTR_INGRESS_STREAM, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_PANEL_TITLE): vol.Coerce(str),
|
vol.Optional(ATTR_PANEL_ICON, default="mdi:puzzle"): str,
|
||||||
|
vol.Optional(ATTR_PANEL_TITLE): str,
|
||||||
vol.Optional(ATTR_PANEL_ADMIN, default=True): vol.Boolean(),
|
vol.Optional(ATTR_PANEL_ADMIN, default=True): vol.Boolean(),
|
||||||
vol.Optional(ATTR_HOMEASSISTANT): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_HOMEASSISTANT): version_tag,
|
||||||
vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(),
|
vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(),
|
vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(),
|
vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_HOST_UTS, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_HOST_DBUS, default=False): vol.Boolean(),
|
vol.Optional(ATTR_HOST_DBUS, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_DEVICES): [vol.Match(r"^(.*):(.*):([rwm]{1,3})$")],
|
vol.Optional(ATTR_DEVICES): [str],
|
||||||
vol.Optional(ATTR_AUTO_UART, default=False): vol.Boolean(),
|
|
||||||
vol.Optional(ATTR_UDEV, default=False): vol.Boolean(),
|
vol.Optional(ATTR_UDEV, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_TMPFS): vol.Match(r"^size=(\d)*[kmg](,uid=\d{1,4})?(,rw)?$"),
|
vol.Optional(ATTR_TMPFS, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_MAP, default=list): [vol.Match(RE_VOLUME)],
|
vol.Optional(ATTR_MAP, default=list): [
|
||||||
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): vol.Coerce(str)},
|
vol.Schema(
|
||||||
vol.Optional(ATTR_PRIVILEGED): [vol.In(PRIVILEGED_ALL)],
|
{
|
||||||
|
vol.Required(ATTR_TYPE): vol.Coerce(MappingType),
|
||||||
|
vol.Optional(ATTR_READ_ONLY, default=True): bool,
|
||||||
|
vol.Optional(ATTR_PATH): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): str},
|
||||||
|
vol.Optional(ATTR_PRIVILEGED): [vol.Coerce(Capabilities)],
|
||||||
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),
|
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),
|
||||||
vol.Optional(ATTR_FULL_ACCESS, default=False): vol.Boolean(),
|
vol.Optional(ATTR_FULL_ACCESS, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(),
|
vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_VIDEO, default=False): vol.Boolean(),
|
vol.Optional(ATTR_VIDEO, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_GPIO, default=False): vol.Boolean(),
|
vol.Optional(ATTR_GPIO, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_USB, default=False): vol.Boolean(),
|
vol.Optional(ATTR_USB, default=False): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_UART, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(),
|
vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_KERNEL_MODULES, default=False): vol.Boolean(),
|
vol.Optional(ATTR_KERNEL_MODULES, default=False): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_REALTIME, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_HASSIO_API, default=False): vol.Boolean(),
|
vol.Optional(ATTR_HASSIO_API, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_HASSIO_ROLE, default=ROLE_DEFAULT): vol.In(ROLE_ALL),
|
vol.Optional(ATTR_HASSIO_ROLE, default=ROLE_DEFAULT): vol.In(ROLE_ALL),
|
||||||
vol.Optional(ATTR_HOMEASSISTANT_API, default=False): vol.Boolean(),
|
vol.Optional(ATTR_HOMEASSISTANT_API, default=False): vol.Boolean(),
|
||||||
@@ -241,51 +383,74 @@ SCHEMA_ADDON_CONFIG = vol.Schema(
|
|||||||
vol.Optional(ATTR_DOCKER_API, default=False): vol.Boolean(),
|
vol.Optional(ATTR_DOCKER_API, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(),
|
vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
|
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
|
||||||
vol.Optional(ATTR_DISCOVERY): [valid_discovery_service],
|
vol.Optional(ATTR_DISCOVERY): [str],
|
||||||
vol.Optional(ATTR_SNAPSHOT_EXCLUDE): [vol.Coerce(str)],
|
vol.Optional(ATTR_BACKUP_EXCLUDE): [str],
|
||||||
vol.Required(ATTR_OPTIONS): dict,
|
vol.Optional(ATTR_BACKUP_PRE): str,
|
||||||
vol.Required(ATTR_SCHEMA): vol.Any(
|
vol.Optional(ATTR_BACKUP_POST): str,
|
||||||
|
vol.Optional(ATTR_BACKUP, default=AddonBackupMode.HOT): vol.Coerce(
|
||||||
|
AddonBackupMode
|
||||||
|
),
|
||||||
|
vol.Optional(ATTR_CODENOTARY): vol.Email(),
|
||||||
|
vol.Optional(ATTR_OPTIONS, default={}): dict,
|
||||||
|
vol.Optional(ATTR_SCHEMA, default={}): vol.Any(
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Coerce(str): vol.Any(
|
str: vol.Any(
|
||||||
SCHEMA_ELEMENT,
|
SCHEMA_ELEMENT,
|
||||||
[
|
[
|
||||||
vol.Any(
|
vol.Any(
|
||||||
SCHEMA_ELEMENT,
|
SCHEMA_ELEMENT,
|
||||||
{
|
{str: vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])},
|
||||||
vol.Coerce(str): vol.Any(
|
|
||||||
SCHEMA_ELEMENT, [SCHEMA_ELEMENT]
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
vol.Schema(
|
vol.Schema({str: vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])}),
|
||||||
{vol.Coerce(str): vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
vol.Optional(ATTR_IMAGE): vol.Match(RE_DOCKER_IMAGE),
|
vol.Optional(ATTR_IMAGE): docker_image,
|
||||||
vol.Optional(ATTR_TIMEOUT, default=10): vol.All(
|
vol.Optional(ATTR_TIMEOUT, default=10): vol.All(
|
||||||
vol.Coerce(int), vol.Range(min=10, max=300)
|
vol.Coerce(int), vol.Range(min=10, max=300)
|
||||||
),
|
),
|
||||||
|
vol.Optional(ATTR_JOURNALD, default=False): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_BREAKING_VERSIONS, default=list): [version_tag],
|
||||||
},
|
},
|
||||||
extra=vol.REMOVE_EXTRA,
|
extra=vol.REMOVE_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SCHEMA_ADDON_CONFIG = vol.All(
|
||||||
|
_migrate_addon_config(True), _warn_addon_config, _SCHEMA_ADDON_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=no-value-for-parameter
|
# pylint: disable=no-value-for-parameter
|
||||||
SCHEMA_BUILD_CONFIG = vol.Schema(
|
SCHEMA_BUILD_CONFIG = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Schema(
|
vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Any(
|
||||||
{vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)}
|
vol.Match(RE_DOCKER_IMAGE_BUILD),
|
||||||
|
vol.Schema({vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)}),
|
||||||
),
|
),
|
||||||
vol.Optional(ATTR_SQUASH, default=False): vol.Boolean(),
|
vol.Optional(ATTR_SQUASH, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_ARGS, default=dict): vol.Schema(
|
vol.Optional(ATTR_ARGS, default=dict): vol.Schema({str: str}),
|
||||||
{vol.Coerce(str): vol.Coerce(str)}
|
vol.Optional(ATTR_LABELS, default=dict): vol.Schema({str: str}),
|
||||||
),
|
},
|
||||||
|
extra=vol.REMOVE_EXTRA,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_TRANSLATION_CONFIGURATION = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_NAME): str,
|
||||||
|
vol.Optional(ATTR_DESCRIPTON): vol.Maybe(str),
|
||||||
|
},
|
||||||
|
extra=vol.REMOVE_EXTRA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_ADDON_TRANSLATIONS = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_CONFIGURATION): {str: SCHEMA_TRANSLATION_CONFIGURATION},
|
||||||
|
vol.Optional(ATTR_NETWORK): {str: str},
|
||||||
},
|
},
|
||||||
extra=vol.REMOVE_EXTRA,
|
extra=vol.REMOVE_EXTRA,
|
||||||
)
|
)
|
||||||
@@ -294,305 +459,55 @@ SCHEMA_BUILD_CONFIG = vol.Schema(
|
|||||||
# pylint: disable=no-value-for-parameter
|
# pylint: disable=no-value-for-parameter
|
||||||
SCHEMA_ADDON_USER = vol.Schema(
|
SCHEMA_ADDON_USER = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_VERSION): vol.Coerce(str),
|
vol.Required(ATTR_VERSION): version_tag,
|
||||||
vol.Optional(ATTR_IMAGE): vol.Coerce(str),
|
vol.Optional(ATTR_IMAGE): docker_image,
|
||||||
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): uuid_match,
|
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): uuid_match,
|
||||||
vol.Optional(ATTR_ACCESS_TOKEN): token,
|
vol.Optional(ATTR_ACCESS_TOKEN): token,
|
||||||
vol.Optional(ATTR_INGRESS_TOKEN, default=secrets.token_urlsafe): vol.Coerce(
|
vol.Optional(ATTR_INGRESS_TOKEN, default=secrets.token_urlsafe): str,
|
||||||
str
|
|
||||||
),
|
|
||||||
vol.Optional(ATTR_OPTIONS, default=dict): dict,
|
vol.Optional(ATTR_OPTIONS, default=dict): dict,
|
||||||
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
|
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
|
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
|
||||||
vol.Optional(ATTR_NETWORK): docker_ports,
|
vol.Optional(ATTR_NETWORK): docker_ports,
|
||||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
|
||||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||||
vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(),
|
vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(),
|
||||||
vol.Optional(ATTR_INGRESS_PANEL, default=False): vol.Boolean(),
|
vol.Optional(ATTR_INGRESS_PANEL, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_WATCHDOG, default=False): vol.Boolean(),
|
vol.Optional(ATTR_WATCHDOG, default=False): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_SYSTEM_MANAGED, default=False): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_SYSTEM_MANAGED_CONFIG_ENTRY, default=None): vol.Maybe(str),
|
||||||
},
|
},
|
||||||
extra=vol.REMOVE_EXTRA,
|
extra=vol.REMOVE_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SCHEMA_ADDON_SYSTEM = vol.All(
|
||||||
SCHEMA_ADDON_SYSTEM = SCHEMA_ADDON_CONFIG.extend(
|
_migrate_addon_config(),
|
||||||
|
_SCHEMA_ADDON_CONFIG.extend(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_LOCATON): vol.Coerce(str),
|
vol.Required(ATTR_LOCATON): str,
|
||||||
vol.Required(ATTR_REPOSITORY): vol.Coerce(str),
|
vol.Required(ATTR_REPOSITORY): str,
|
||||||
|
vol.Required(ATTR_TRANSLATIONS, default=dict): {
|
||||||
|
str: SCHEMA_ADDON_TRANSLATIONS
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_ADDONS_FILE = vol.Schema(
|
SCHEMA_ADDONS_FILE = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_USER, default=dict): {vol.Coerce(str): SCHEMA_ADDON_USER},
|
vol.Optional(ATTR_USER, default=dict): {str: SCHEMA_ADDON_USER},
|
||||||
vol.Optional(ATTR_SYSTEM, default=dict): {vol.Coerce(str): SCHEMA_ADDON_SYSTEM},
|
vol.Optional(ATTR_SYSTEM, default=dict): {str: SCHEMA_ADDON_SYSTEM},
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_ADDON_SNAPSHOT = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_USER): SCHEMA_ADDON_USER,
|
|
||||||
vol.Required(ATTR_SYSTEM): SCHEMA_ADDON_SYSTEM,
|
|
||||||
vol.Required(ATTR_STATE): vol.Coerce(AddonState),
|
|
||||||
vol.Required(ATTR_VERSION): vol.Coerce(str),
|
|
||||||
},
|
},
|
||||||
extra=vol.REMOVE_EXTRA,
|
extra=vol.REMOVE_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_options(coresys: CoreSys, raw_schema: Dict[str, Any]):
|
SCHEMA_ADDON_BACKUP = vol.Schema(
|
||||||
"""Validate schema."""
|
{
|
||||||
|
vol.Required(ATTR_USER): SCHEMA_ADDON_USER,
|
||||||
def validate(struct):
|
vol.Required(ATTR_SYSTEM): SCHEMA_ADDON_SYSTEM,
|
||||||
"""Create schema validator for add-ons options."""
|
vol.Required(ATTR_STATE): vol.Coerce(AddonState),
|
||||||
options = {}
|
vol.Required(ATTR_VERSION): version_tag,
|
||||||
|
},
|
||||||
# read options
|
extra=vol.REMOVE_EXTRA,
|
||||||
for key, value in struct.items():
|
|
||||||
# Ignore unknown options / remove from list
|
|
||||||
if key not in raw_schema:
|
|
||||||
_LOGGER.warning("Unknown options %s", key)
|
|
||||||
continue
|
|
||||||
|
|
||||||
typ = raw_schema[key]
|
|
||||||
try:
|
|
||||||
if isinstance(typ, list):
|
|
||||||
# nested value list
|
|
||||||
options[key] = _nested_validate_list(coresys, typ[0], value, key)
|
|
||||||
elif isinstance(typ, dict):
|
|
||||||
# nested value dict
|
|
||||||
options[key] = _nested_validate_dict(coresys, typ, value, key)
|
|
||||||
else:
|
|
||||||
# normal value
|
|
||||||
options[key] = _single_validate(coresys, typ, value, key)
|
|
||||||
except (IndexError, KeyError):
|
|
||||||
raise vol.Invalid(f"Type error for {key}") from None
|
|
||||||
|
|
||||||
_check_missing_options(raw_schema, options, "root")
|
|
||||||
return options
|
|
||||||
|
|
||||||
return validate
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=no-value-for-parameter
|
|
||||||
# pylint: disable=inconsistent-return-statements
|
|
||||||
def _single_validate(coresys: CoreSys, typ: str, value: Any, key: str):
|
|
||||||
"""Validate a single element."""
|
|
||||||
# if required argument
|
|
||||||
if value is None:
|
|
||||||
raise vol.Invalid(f"Missing required option '{key}'") from None
|
|
||||||
|
|
||||||
# Lookup secret
|
|
||||||
if str(value).startswith("!secret "):
|
|
||||||
secret: str = value.partition(" ")[2]
|
|
||||||
value = coresys.homeassistant.secrets.get(secret)
|
|
||||||
if value is None:
|
|
||||||
raise vol.Invalid(f"Unknown secret {secret}") from None
|
|
||||||
|
|
||||||
# parse extend data from type
|
|
||||||
match = RE_SCHEMA_ELEMENT.match(typ)
|
|
||||||
|
|
||||||
if not match:
|
|
||||||
raise vol.Invalid(f"Unknown type {typ}") from None
|
|
||||||
|
|
||||||
# prepare range
|
|
||||||
range_args = {}
|
|
||||||
for group_name in _SCHEMA_LENGTH_PARTS:
|
|
||||||
group_value = match.group(group_name)
|
|
||||||
if group_value:
|
|
||||||
range_args[group_name[2:]] = float(group_value)
|
|
||||||
|
|
||||||
if typ.startswith(V_STR) or typ.startswith(V_PASSWORD):
|
|
||||||
return vol.All(str(value), vol.Range(**range_args))(value)
|
|
||||||
elif typ.startswith(V_INT):
|
|
||||||
return vol.All(vol.Coerce(int), vol.Range(**range_args))(value)
|
|
||||||
elif typ.startswith(V_FLOAT):
|
|
||||||
return vol.All(vol.Coerce(float), vol.Range(**range_args))(value)
|
|
||||||
elif typ.startswith(V_BOOL):
|
|
||||||
return vol.Boolean()(value)
|
|
||||||
elif typ.startswith(V_EMAIL):
|
|
||||||
return vol.Email()(value)
|
|
||||||
elif typ.startswith(V_URL):
|
|
||||||
return vol.Url()(value)
|
|
||||||
elif typ.startswith(V_PORT):
|
|
||||||
return network_port(value)
|
|
||||||
elif typ.startswith(V_MATCH):
|
|
||||||
return vol.Match(match.group("match"))(str(value))
|
|
||||||
elif typ.startswith(V_LIST):
|
|
||||||
return vol.In(match.group("list").split("|"))(str(value))
|
|
||||||
|
|
||||||
raise vol.Invalid(f"Fatal error for {key} type {typ}") from None
|
|
||||||
|
|
||||||
|
|
||||||
def _nested_validate_list(coresys, typ, data_list, key):
|
|
||||||
"""Validate nested items."""
|
|
||||||
options = []
|
|
||||||
|
|
||||||
# Make sure it is a list
|
|
||||||
if not isinstance(data_list, list):
|
|
||||||
raise vol.Invalid(f"Invalid list for {key}") from None
|
|
||||||
|
|
||||||
# Process list
|
|
||||||
for element in data_list:
|
|
||||||
# Nested?
|
|
||||||
if isinstance(typ, dict):
|
|
||||||
c_options = _nested_validate_dict(coresys, typ, element, key)
|
|
||||||
options.append(c_options)
|
|
||||||
else:
|
|
||||||
options.append(_single_validate(coresys, typ, element, key))
|
|
||||||
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def _nested_validate_dict(coresys, typ, data_dict, key):
|
|
||||||
"""Validate nested items."""
|
|
||||||
options = {}
|
|
||||||
|
|
||||||
# Make sure it is a dict
|
|
||||||
if not isinstance(data_dict, dict):
|
|
||||||
raise vol.Invalid(f"Invalid dict for {key}") from None
|
|
||||||
|
|
||||||
# Process dict
|
|
||||||
for c_key, c_value in data_dict.items():
|
|
||||||
# Ignore unknown options / remove from list
|
|
||||||
if c_key not in typ:
|
|
||||||
_LOGGER.warning("Unknown options %s", c_key)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Nested?
|
|
||||||
if isinstance(typ[c_key], list):
|
|
||||||
options[c_key] = _nested_validate_list(
|
|
||||||
coresys, typ[c_key][0], c_value, c_key
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
options[c_key] = _single_validate(coresys, typ[c_key], c_value, c_key)
|
|
||||||
|
|
||||||
_check_missing_options(typ, options, key)
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def _check_missing_options(origin, exists, root):
|
|
||||||
"""Check if all options are exists."""
|
|
||||||
missing = set(origin) - set(exists)
|
|
||||||
for miss_opt in missing:
|
|
||||||
if isinstance(origin[miss_opt], str) and origin[miss_opt].endswith("?"):
|
|
||||||
continue
|
|
||||||
raise vol.Invalid(f"Missing option {miss_opt} in {root}") from None
|
|
||||||
|
|
||||||
|
|
||||||
def schema_ui_options(raw_schema: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
||||||
"""Generate UI schema."""
|
|
||||||
ui_schema: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
# read options
|
|
||||||
for key, value in raw_schema.items():
|
|
||||||
if isinstance(value, list):
|
|
||||||
# nested value list
|
|
||||||
_nested_ui_list(ui_schema, value, key)
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
# nested value dict
|
|
||||||
_nested_ui_dict(ui_schema, value, key)
|
|
||||||
else:
|
|
||||||
# normal value
|
|
||||||
_single_ui_option(ui_schema, value, key)
|
|
||||||
|
|
||||||
return ui_schema
|
|
||||||
|
|
||||||
|
|
||||||
def _single_ui_option(
|
|
||||||
ui_schema: List[Dict[str, Any]], value: str, key: str, multiple: bool = False
|
|
||||||
) -> None:
|
|
||||||
"""Validate a single element."""
|
|
||||||
ui_node: Dict[str, Union[str, bool, float, List[str]]] = {"name": key}
|
|
||||||
|
|
||||||
# If multiple
|
|
||||||
if multiple:
|
|
||||||
ui_node["multiple"] = True
|
|
||||||
|
|
||||||
# Parse extend data from type
|
|
||||||
match = RE_SCHEMA_ELEMENT.match(value)
|
|
||||||
if not match:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Prepare range
|
|
||||||
for group_name in _SCHEMA_LENGTH_PARTS:
|
|
||||||
group_value = match.group(group_name)
|
|
||||||
if not group_value:
|
|
||||||
continue
|
|
||||||
if group_name[2:] == "min":
|
|
||||||
ui_node["lengthMin"] = float(group_value)
|
|
||||||
elif group_name[2:] == "max":
|
|
||||||
ui_node["lengthMax"] = float(group_value)
|
|
||||||
|
|
||||||
# If required
|
|
||||||
if value.endswith("?"):
|
|
||||||
ui_node["optional"] = True
|
|
||||||
else:
|
|
||||||
ui_node["required"] = True
|
|
||||||
|
|
||||||
# Data types
|
|
||||||
if value.startswith(V_STR):
|
|
||||||
ui_node["type"] = "string"
|
|
||||||
elif value.startswith(V_PASSWORD):
|
|
||||||
ui_node["type"] = "string"
|
|
||||||
ui_node["format"] = "password"
|
|
||||||
elif value.startswith(V_INT):
|
|
||||||
ui_node["type"] = "integer"
|
|
||||||
elif value.startswith(V_FLOAT):
|
|
||||||
ui_node["type"] = "float"
|
|
||||||
elif value.startswith(V_BOOL):
|
|
||||||
ui_node["type"] = "boolean"
|
|
||||||
elif value.startswith(V_EMAIL):
|
|
||||||
ui_node["type"] = "string"
|
|
||||||
ui_node["format"] = "email"
|
|
||||||
elif value.startswith(V_URL):
|
|
||||||
ui_node["type"] = "string"
|
|
||||||
ui_node["format"] = "url"
|
|
||||||
elif value.startswith(V_PORT):
|
|
||||||
ui_node["type"] = "integer"
|
|
||||||
elif value.startswith(V_MATCH):
|
|
||||||
ui_node["type"] = "string"
|
|
||||||
elif value.startswith(V_LIST):
|
|
||||||
ui_node["type"] = "select"
|
|
||||||
ui_node["options"] = match.group("list").split("|")
|
|
||||||
|
|
||||||
ui_schema.append(ui_node)
|
|
||||||
|
|
||||||
|
|
||||||
def _nested_ui_list(
|
|
||||||
ui_schema: List[Dict[str, Any]], option_list: List[Any], key: str
|
|
||||||
) -> None:
|
|
||||||
"""UI nested list items."""
|
|
||||||
try:
|
|
||||||
element = option_list[0]
|
|
||||||
except IndexError:
|
|
||||||
_LOGGER.error("Invalid schema %s", key)
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(element, dict):
|
|
||||||
_nested_ui_dict(ui_schema, element, key, multiple=True)
|
|
||||||
else:
|
|
||||||
_single_ui_option(ui_schema, element, key, multiple=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _nested_ui_dict(
|
|
||||||
ui_schema: List[Dict[str, Any]],
|
|
||||||
option_dict: Dict[str, Any],
|
|
||||||
key: str,
|
|
||||||
multiple: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""UI nested dict items."""
|
|
||||||
ui_node = {"name": key, "type": "schema", "optional": True, "multiple": multiple}
|
|
||||||
|
|
||||||
nested_schema = []
|
|
||||||
for c_key, c_value in option_dict.items():
|
|
||||||
# Nested?
|
|
||||||
if isinstance(c_value, list):
|
|
||||||
_nested_ui_list(nested_schema, c_value, c_key)
|
|
||||||
else:
|
|
||||||
_single_ui_option(nested_schema, c_value, c_key)
|
|
||||||
|
|
||||||
ui_node["schema"] = nested_schema
|
|
||||||
ui_schema.append(ui_node)
|
|
||||||
|
|||||||
@@ -1,39 +1,50 @@
|
|||||||
"""Init file for Supervisor RESTful API."""
|
"""Init file for Supervisor RESTful API."""
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ..const import AddonState
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
|
from ..exceptions import APIAddonNotInstalled, HostNotSupportedError
|
||||||
|
from ..utils.sentry import capture_exception
|
||||||
from .addons import APIAddons
|
from .addons import APIAddons
|
||||||
from .audio import APIAudio
|
from .audio import APIAudio
|
||||||
from .auth import APIAuth
|
from .auth import APIAuth
|
||||||
|
from .backups import APIBackups
|
||||||
from .cli import APICli
|
from .cli import APICli
|
||||||
|
from .const import CONTENT_TYPE_TEXT
|
||||||
from .discovery import APIDiscovery
|
from .discovery import APIDiscovery
|
||||||
from .dns import APICoreDNS
|
from .dns import APICoreDNS
|
||||||
from .docker import APIDocker
|
from .docker import APIDocker
|
||||||
from .hardware import APIHardware
|
from .hardware import APIHardware
|
||||||
from .homeassistant import APIHomeAssistant
|
from .homeassistant import APIHomeAssistant
|
||||||
from .host import APIHost
|
from .host import APIHost
|
||||||
from .info import APIInfo
|
|
||||||
from .ingress import APIIngress
|
from .ingress import APIIngress
|
||||||
from .jobs import APIJobs
|
from .jobs import APIJobs
|
||||||
|
from .middleware.security import SecurityMiddleware
|
||||||
|
from .mounts import APIMounts
|
||||||
from .multicast import APIMulticast
|
from .multicast import APIMulticast
|
||||||
from .network import APINetwork
|
from .network import APINetwork
|
||||||
from .observer import APIObserver
|
from .observer import APIObserver
|
||||||
from .os import APIOS
|
from .os import APIOS
|
||||||
from .proxy import APIProxy
|
from .proxy import APIProxy
|
||||||
from .resolution import APIResoulution
|
from .resolution import APIResoulution
|
||||||
from .security import SecurityMiddleware
|
from .root import APIRoot
|
||||||
|
from .security import APISecurity
|
||||||
from .services import APIServices
|
from .services import APIServices
|
||||||
from .snapshots import APISnapshots
|
from .store import APIStore
|
||||||
from .supervisor import APISupervisor
|
from .supervisor import APISupervisor
|
||||||
|
from .utils import api_process, api_process_raw
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
MAX_CLIENT_SIZE: int = 1024**2 * 16
|
MAX_CLIENT_SIZE: int = 1024**2 * 16
|
||||||
|
MAX_LINE_SIZE: int = 24570
|
||||||
|
|
||||||
|
|
||||||
class RestAPI(CoreSysAttributes):
|
class RestAPI(CoreSysAttributes):
|
||||||
@@ -46,20 +57,33 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self.webapp: web.Application = web.Application(
|
self.webapp: web.Application = web.Application(
|
||||||
client_max_size=MAX_CLIENT_SIZE,
|
client_max_size=MAX_CLIENT_SIZE,
|
||||||
middlewares=[
|
middlewares=[
|
||||||
|
self.security.block_bad_requests,
|
||||||
self.security.system_validation,
|
self.security.system_validation,
|
||||||
self.security.token_validation,
|
self.security.token_validation,
|
||||||
|
self.security.core_proxy,
|
||||||
],
|
],
|
||||||
|
handler_args={
|
||||||
|
"max_line_size": MAX_LINE_SIZE,
|
||||||
|
"max_field_size": MAX_LINE_SIZE,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# service stuff
|
# service stuff
|
||||||
self._runner: web.AppRunner = web.AppRunner(self.webapp)
|
self._runner: web.AppRunner = web.AppRunner(self.webapp, shutdown_timeout=5)
|
||||||
self._site: Optional[web.TCPSite] = None
|
self._site: web.TCPSite | None = None
|
||||||
|
|
||||||
|
# share single host API handler for reuse in logging endpoints
|
||||||
|
self._api_host: APIHost | None = None
|
||||||
|
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Register REST API Calls."""
|
"""Register REST API Calls."""
|
||||||
|
self._api_host = APIHost()
|
||||||
|
self._api_host.coresys = self.coresys
|
||||||
|
|
||||||
self._register_addons()
|
self._register_addons()
|
||||||
self._register_audio()
|
self._register_audio()
|
||||||
self._register_auth()
|
self._register_auth()
|
||||||
|
self._register_backups()
|
||||||
self._register_cli()
|
self._register_cli()
|
||||||
self._register_discovery()
|
self._register_discovery()
|
||||||
self._register_dns()
|
self._register_dns()
|
||||||
@@ -67,40 +91,93 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self._register_hardware()
|
self._register_hardware()
|
||||||
self._register_homeassistant()
|
self._register_homeassistant()
|
||||||
self._register_host()
|
self._register_host()
|
||||||
self._register_info()
|
self._register_jobs()
|
||||||
self._register_ingress()
|
self._register_ingress()
|
||||||
|
self._register_mounts()
|
||||||
self._register_multicast()
|
self._register_multicast()
|
||||||
self._register_network()
|
self._register_network()
|
||||||
self._register_observer()
|
self._register_observer()
|
||||||
self._register_os()
|
self._register_os()
|
||||||
self._register_jobs()
|
|
||||||
self._register_panel()
|
self._register_panel()
|
||||||
self._register_proxy()
|
self._register_proxy()
|
||||||
self._register_resolution()
|
self._register_resolution()
|
||||||
|
self._register_root()
|
||||||
|
self._register_security()
|
||||||
self._register_services()
|
self._register_services()
|
||||||
self._register_snapshots()
|
self._register_store()
|
||||||
self._register_supervisor()
|
self._register_supervisor()
|
||||||
|
|
||||||
await self.start()
|
await self.start()
|
||||||
|
|
||||||
|
def _register_advanced_logs(self, path: str, syslog_identifier: str):
|
||||||
|
"""Register logs endpoint for a given path, returning logs for single syslog identifier."""
|
||||||
|
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.get(
|
||||||
|
f"{path}/logs",
|
||||||
|
partial(self._api_host.advanced_logs, identifier=syslog_identifier),
|
||||||
|
),
|
||||||
|
web.get(
|
||||||
|
f"{path}/logs/follow",
|
||||||
|
partial(
|
||||||
|
self._api_host.advanced_logs,
|
||||||
|
identifier=syslog_identifier,
|
||||||
|
follow=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
web.get(
|
||||||
|
f"{path}/logs/boots/{{bootid}}",
|
||||||
|
partial(self._api_host.advanced_logs, identifier=syslog_identifier),
|
||||||
|
),
|
||||||
|
web.get(
|
||||||
|
f"{path}/logs/boots/{{bootid}}/follow",
|
||||||
|
partial(
|
||||||
|
self._api_host.advanced_logs,
|
||||||
|
identifier=syslog_identifier,
|
||||||
|
follow=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def _register_host(self) -> None:
|
def _register_host(self) -> None:
|
||||||
"""Register hostcontrol functions."""
|
"""Register hostcontrol functions."""
|
||||||
api_host = APIHost()
|
api_host = self._api_host
|
||||||
api_host.coresys = self.coresys
|
|
||||||
|
|
||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
web.get("/host/info", api_host.info),
|
web.get("/host/info", api_host.info),
|
||||||
web.get("/host/logs", api_host.logs),
|
web.get("/host/logs", api_host.advanced_logs),
|
||||||
|
web.get(
|
||||||
|
"/host/logs/follow",
|
||||||
|
partial(api_host.advanced_logs, follow=True),
|
||||||
|
),
|
||||||
|
web.get("/host/logs/identifiers", api_host.list_identifiers),
|
||||||
|
web.get("/host/logs/identifiers/{identifier}", api_host.advanced_logs),
|
||||||
|
web.get(
|
||||||
|
"/host/logs/identifiers/{identifier}/follow",
|
||||||
|
partial(api_host.advanced_logs, follow=True),
|
||||||
|
),
|
||||||
|
web.get("/host/logs/boots", api_host.list_boots),
|
||||||
|
web.get("/host/logs/boots/{bootid}", api_host.advanced_logs),
|
||||||
|
web.get(
|
||||||
|
"/host/logs/boots/{bootid}/follow",
|
||||||
|
partial(api_host.advanced_logs, follow=True),
|
||||||
|
),
|
||||||
|
web.get(
|
||||||
|
"/host/logs/boots/{bootid}/identifiers/{identifier}",
|
||||||
|
api_host.advanced_logs,
|
||||||
|
),
|
||||||
|
web.get(
|
||||||
|
"/host/logs/boots/{bootid}/identifiers/{identifier}/follow",
|
||||||
|
partial(api_host.advanced_logs, follow=True),
|
||||||
|
),
|
||||||
web.post("/host/reboot", api_host.reboot),
|
web.post("/host/reboot", api_host.reboot),
|
||||||
web.post("/host/shutdown", api_host.shutdown),
|
web.post("/host/shutdown", api_host.shutdown),
|
||||||
web.post("/host/reload", api_host.reload),
|
web.post("/host/reload", api_host.reload),
|
||||||
web.post("/host/options", api_host.options),
|
web.post("/host/options", api_host.options),
|
||||||
web.get("/host/services", api_host.services),
|
web.get("/host/services", api_host.services),
|
||||||
web.post("/host/services/{service}/stop", api_host.service_stop),
|
|
||||||
web.post("/host/services/{service}/start", api_host.service_start),
|
|
||||||
web.post("/host/services/{service}/restart", api_host.service_restart),
|
|
||||||
web.post("/host/services/{service}/reload", api_host.service_reload),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,6 +189,7 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
web.get("/network/info", api_network.info),
|
web.get("/network/info", api_network.info),
|
||||||
|
web.post("/network/reload", api_network.reload),
|
||||||
web.get(
|
web.get(
|
||||||
"/network/interface/{interface}/info", api_network.interface_info
|
"/network/interface/{interface}/info", api_network.interface_info
|
||||||
),
|
),
|
||||||
@@ -140,6 +218,34 @@ class RestAPI(CoreSysAttributes):
|
|||||||
web.get("/os/info", api_os.info),
|
web.get("/os/info", api_os.info),
|
||||||
web.post("/os/update", api_os.update),
|
web.post("/os/update", api_os.update),
|
||||||
web.post("/os/config/sync", api_os.config_sync),
|
web.post("/os/config/sync", api_os.config_sync),
|
||||||
|
web.post("/os/datadisk/move", api_os.migrate_data),
|
||||||
|
web.get("/os/datadisk/list", api_os.list_data),
|
||||||
|
web.post("/os/datadisk/wipe", api_os.wipe_data),
|
||||||
|
web.post("/os/boot-slot", api_os.set_boot_slot),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Boards endpoints
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/os/boards/green", api_os.boards_green_info),
|
||||||
|
web.post("/os/boards/green", api_os.boards_green_options),
|
||||||
|
web.get("/os/boards/yellow", api_os.boards_yellow_info),
|
||||||
|
web.post("/os/boards/yellow", api_os.boards_yellow_options),
|
||||||
|
web.get("/os/boards/{board}", api_os.boards_other_info),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _register_security(self) -> None:
|
||||||
|
"""Register Security functions."""
|
||||||
|
api_security = APISecurity()
|
||||||
|
api_security.coresys = self.coresys
|
||||||
|
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/security/info", api_security.info),
|
||||||
|
web.post("/security/options", api_security.options),
|
||||||
|
web.post("/security/integrity", api_security.integrity_check),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,6 +259,8 @@ class RestAPI(CoreSysAttributes):
|
|||||||
web.get("/jobs/info", api_jobs.info),
|
web.get("/jobs/info", api_jobs.info),
|
||||||
web.post("/jobs/options", api_jobs.options),
|
web.post("/jobs/options", api_jobs.options),
|
||||||
web.post("/jobs/reset", api_jobs.reset),
|
web.post("/jobs/reset", api_jobs.reset),
|
||||||
|
web.get("/jobs/{uuid}", api_jobs.job_info),
|
||||||
|
web.delete("/jobs/{uuid}", api_jobs.remove_job),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,11 +299,11 @@ class RestAPI(CoreSysAttributes):
|
|||||||
[
|
[
|
||||||
web.get("/multicast/info", api_multicast.info),
|
web.get("/multicast/info", api_multicast.info),
|
||||||
web.get("/multicast/stats", api_multicast.stats),
|
web.get("/multicast/stats", api_multicast.stats),
|
||||||
web.get("/multicast/logs", api_multicast.logs),
|
|
||||||
web.post("/multicast/update", api_multicast.update),
|
web.post("/multicast/update", api_multicast.update),
|
||||||
web.post("/multicast/restart", api_multicast.restart),
|
web.post("/multicast/restart", api_multicast.restart),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
self._register_advanced_logs("/multicast", "hassio_multicast")
|
||||||
|
|
||||||
def _register_hardware(self) -> None:
|
def _register_hardware(self) -> None:
|
||||||
"""Register hardware functions."""
|
"""Register hardware functions."""
|
||||||
@@ -206,16 +314,24 @@ class RestAPI(CoreSysAttributes):
|
|||||||
[
|
[
|
||||||
web.get("/hardware/info", api_hardware.info),
|
web.get("/hardware/info", api_hardware.info),
|
||||||
web.get("/hardware/audio", api_hardware.audio),
|
web.get("/hardware/audio", api_hardware.audio),
|
||||||
web.post("/hardware/trigger", api_hardware.trigger),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _register_info(self) -> None:
|
def _register_root(self) -> None:
|
||||||
"""Register info functions."""
|
"""Register root functions."""
|
||||||
api_info = APIInfo()
|
api_root = APIRoot()
|
||||||
api_info.coresys = self.coresys
|
api_root.coresys = self.coresys
|
||||||
|
|
||||||
self.webapp.add_routes([web.get("/info", api_info.info)])
|
self.webapp.add_routes([web.get("/info", api_root.info)])
|
||||||
|
self.webapp.add_routes([web.post("/refresh_updates", api_root.refresh_updates)])
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[web.get("/available_updates", api_root.available_updates)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove: 2023
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[web.get("/supervisor/available_updates", api_root.available_updates)]
|
||||||
|
)
|
||||||
|
|
||||||
def _register_resolution(self) -> None:
|
def _register_resolution(self) -> None:
|
||||||
"""Register info functions."""
|
"""Register info functions."""
|
||||||
@@ -225,6 +341,10 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
web.get("/resolution/info", api_resolution.info),
|
web.get("/resolution/info", api_resolution.info),
|
||||||
|
web.post(
|
||||||
|
"/resolution/check/{check}/options", api_resolution.options_check
|
||||||
|
),
|
||||||
|
web.post("/resolution/check/{check}/run", api_resolution.run_check),
|
||||||
web.post(
|
web.post(
|
||||||
"/resolution/suggestion/{suggestion}",
|
"/resolution/suggestion/{suggestion}",
|
||||||
api_resolution.apply_suggestion,
|
api_resolution.apply_suggestion,
|
||||||
@@ -237,6 +357,11 @@ class RestAPI(CoreSysAttributes):
|
|||||||
"/resolution/issue/{issue}",
|
"/resolution/issue/{issue}",
|
||||||
api_resolution.dismiss_issue,
|
api_resolution.dismiss_issue,
|
||||||
),
|
),
|
||||||
|
web.get(
|
||||||
|
"/resolution/issue/{issue}/suggestions",
|
||||||
|
api_resolution.suggestions_for_issue,
|
||||||
|
),
|
||||||
|
web.post("/resolution/healthcheck", api_resolution.healthcheck),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -247,9 +372,11 @@ class RestAPI(CoreSysAttributes):
|
|||||||
|
|
||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
|
web.get("/auth", api_auth.auth),
|
||||||
web.post("/auth", api_auth.auth),
|
web.post("/auth", api_auth.auth),
|
||||||
web.post("/auth/reset", api_auth.reset),
|
web.post("/auth/reset", api_auth.reset),
|
||||||
web.delete("/auth/cache", api_auth.cache),
|
web.delete("/auth/cache", api_auth.cache),
|
||||||
|
web.get("/auth/list", api_auth.list_users),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -263,7 +390,6 @@ class RestAPI(CoreSysAttributes):
|
|||||||
web.get("/supervisor/ping", api_supervisor.ping),
|
web.get("/supervisor/ping", api_supervisor.ping),
|
||||||
web.get("/supervisor/info", api_supervisor.info),
|
web.get("/supervisor/info", api_supervisor.info),
|
||||||
web.get("/supervisor/stats", api_supervisor.stats),
|
web.get("/supervisor/stats", api_supervisor.stats),
|
||||||
web.get("/supervisor/logs", api_supervisor.logs),
|
|
||||||
web.post("/supervisor/update", api_supervisor.update),
|
web.post("/supervisor/update", api_supervisor.update),
|
||||||
web.post("/supervisor/reload", api_supervisor.reload),
|
web.post("/supervisor/reload", api_supervisor.reload),
|
||||||
web.post("/supervisor/restart", api_supervisor.restart),
|
web.post("/supervisor/restart", api_supervisor.restart),
|
||||||
@@ -272,6 +398,38 @@ class RestAPI(CoreSysAttributes):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_supervisor_logs(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return await self._api_host.advanced_logs_handler(
|
||||||
|
*args, identifier="hassio_supervisor", **kwargs
|
||||||
|
)
|
||||||
|
except Exception as err: # pylint: disable=broad-exception-caught
|
||||||
|
# Supervisor logs are critical, so catch everything, log the exception
|
||||||
|
# and try to return Docker container logs as the fallback
|
||||||
|
_LOGGER.exception(
|
||||||
|
"Failed to get supervisor logs using advanced_logs API"
|
||||||
|
)
|
||||||
|
if not isinstance(err, HostNotSupportedError):
|
||||||
|
# No need to capture HostNotSupportedError to Sentry, the cause
|
||||||
|
# is known and reported to the user using the resolution center.
|
||||||
|
capture_exception(err)
|
||||||
|
return await api_supervisor.logs(*args, **kwargs)
|
||||||
|
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/supervisor/logs", get_supervisor_logs),
|
||||||
|
web.get(
|
||||||
|
"/supervisor/logs/follow",
|
||||||
|
partial(get_supervisor_logs, follow=True),
|
||||||
|
),
|
||||||
|
web.get("/supervisor/logs/boots/{bootid}", get_supervisor_logs),
|
||||||
|
web.get(
|
||||||
|
"/supervisor/logs/boots/{bootid}/follow",
|
||||||
|
partial(get_supervisor_logs, follow=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def _register_homeassistant(self) -> None:
|
def _register_homeassistant(self) -> None:
|
||||||
"""Register Home Assistant functions."""
|
"""Register Home Assistant functions."""
|
||||||
api_hass = APIHomeAssistant()
|
api_hass = APIHomeAssistant()
|
||||||
@@ -280,7 +438,6 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
web.get("/core/info", api_hass.info),
|
web.get("/core/info", api_hass.info),
|
||||||
web.get("/core/logs", api_hass.logs),
|
|
||||||
web.get("/core/stats", api_hass.stats),
|
web.get("/core/stats", api_hass.stats),
|
||||||
web.post("/core/options", api_hass.options),
|
web.post("/core/options", api_hass.options),
|
||||||
web.post("/core/update", api_hass.update),
|
web.post("/core/update", api_hass.update),
|
||||||
@@ -289,20 +446,28 @@ class RestAPI(CoreSysAttributes):
|
|||||||
web.post("/core/start", api_hass.start),
|
web.post("/core/start", api_hass.start),
|
||||||
web.post("/core/check", api_hass.check),
|
web.post("/core/check", api_hass.check),
|
||||||
web.post("/core/rebuild", api_hass.rebuild),
|
web.post("/core/rebuild", api_hass.rebuild),
|
||||||
# Remove with old Supervisor fallback
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self._register_advanced_logs("/core", "homeassistant")
|
||||||
|
|
||||||
|
# Reroute from legacy
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
web.get("/homeassistant/info", api_hass.info),
|
web.get("/homeassistant/info", api_hass.info),
|
||||||
web.get("/homeassistant/logs", api_hass.logs),
|
|
||||||
web.get("/homeassistant/stats", api_hass.stats),
|
web.get("/homeassistant/stats", api_hass.stats),
|
||||||
web.post("/homeassistant/options", api_hass.options),
|
web.post("/homeassistant/options", api_hass.options),
|
||||||
web.post("/homeassistant/update", api_hass.update),
|
|
||||||
web.post("/homeassistant/restart", api_hass.restart),
|
web.post("/homeassistant/restart", api_hass.restart),
|
||||||
web.post("/homeassistant/stop", api_hass.stop),
|
web.post("/homeassistant/stop", api_hass.stop),
|
||||||
web.post("/homeassistant/start", api_hass.start),
|
web.post("/homeassistant/start", api_hass.start),
|
||||||
web.post("/homeassistant/check", api_hass.check),
|
web.post("/homeassistant/update", api_hass.update),
|
||||||
web.post("/homeassistant/rebuild", api_hass.rebuild),
|
web.post("/homeassistant/rebuild", api_hass.rebuild),
|
||||||
|
web.post("/homeassistant/check", api_hass.check),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._register_advanced_logs("/homeassistant", "homeassistant")
|
||||||
|
|
||||||
def _register_proxy(self) -> None:
|
def _register_proxy(self) -> None:
|
||||||
"""Register Home Assistant API Proxy."""
|
"""Register Home Assistant API Proxy."""
|
||||||
api_proxy = APIProxy()
|
api_proxy = APIProxy()
|
||||||
@@ -316,7 +481,12 @@ class RestAPI(CoreSysAttributes):
|
|||||||
web.post("/core/api/{path:.+}", api_proxy.api),
|
web.post("/core/api/{path:.+}", api_proxy.api),
|
||||||
web.get("/core/api/{path:.+}", api_proxy.api),
|
web.get("/core/api/{path:.+}", api_proxy.api),
|
||||||
web.get("/core/api/", api_proxy.api),
|
web.get("/core/api/", api_proxy.api),
|
||||||
# Remove with old Supervisor fallback
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reroute from legacy
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
web.get("/homeassistant/api/websocket", api_proxy.websocket),
|
web.get("/homeassistant/api/websocket", api_proxy.websocket),
|
||||||
web.get("/homeassistant/websocket", api_proxy.websocket),
|
web.get("/homeassistant/websocket", api_proxy.websocket),
|
||||||
web.get("/homeassistant/api/stream", api_proxy.stream),
|
web.get("/homeassistant/api/stream", api_proxy.stream),
|
||||||
@@ -334,30 +504,63 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
web.get("/addons", api_addons.list),
|
web.get("/addons", api_addons.list),
|
||||||
web.post("/addons/reload", api_addons.reload),
|
|
||||||
web.get("/addons/{addon}/info", api_addons.info),
|
|
||||||
web.post("/addons/{addon}/install", api_addons.install),
|
|
||||||
web.post("/addons/{addon}/uninstall", api_addons.uninstall),
|
web.post("/addons/{addon}/uninstall", api_addons.uninstall),
|
||||||
web.post("/addons/{addon}/start", api_addons.start),
|
web.post("/addons/{addon}/start", api_addons.start),
|
||||||
web.post("/addons/{addon}/stop", api_addons.stop),
|
web.post("/addons/{addon}/stop", api_addons.stop),
|
||||||
web.post("/addons/{addon}/restart", api_addons.restart),
|
web.post("/addons/{addon}/restart", api_addons.restart),
|
||||||
web.post("/addons/{addon}/update", api_addons.update),
|
|
||||||
web.post("/addons/{addon}/options", api_addons.options),
|
web.post("/addons/{addon}/options", api_addons.options),
|
||||||
|
web.post("/addons/{addon}/sys_options", api_addons.sys_options),
|
||||||
web.post(
|
web.post(
|
||||||
"/addons/{addon}/options/validate", api_addons.options_validate
|
"/addons/{addon}/options/validate", api_addons.options_validate
|
||||||
),
|
),
|
||||||
|
web.get("/addons/{addon}/options/config", api_addons.options_config),
|
||||||
web.post("/addons/{addon}/rebuild", api_addons.rebuild),
|
web.post("/addons/{addon}/rebuild", api_addons.rebuild),
|
||||||
web.get("/addons/{addon}/logs", api_addons.logs),
|
|
||||||
web.get("/addons/{addon}/icon", api_addons.icon),
|
|
||||||
web.get("/addons/{addon}/logo", api_addons.logo),
|
|
||||||
web.get("/addons/{addon}/changelog", api_addons.changelog),
|
|
||||||
web.get("/addons/{addon}/documentation", api_addons.documentation),
|
|
||||||
web.post("/addons/{addon}/stdin", api_addons.stdin),
|
web.post("/addons/{addon}/stdin", api_addons.stdin),
|
||||||
web.post("/addons/{addon}/security", api_addons.security),
|
web.post("/addons/{addon}/security", api_addons.security),
|
||||||
web.get("/addons/{addon}/stats", api_addons.stats),
|
web.get("/addons/{addon}/stats", api_addons.stats),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT)
|
||||||
|
async def get_addon_logs(request, *args, **kwargs):
|
||||||
|
addon = api_addons.get_addon_for_request(request)
|
||||||
|
kwargs["identifier"] = f"addon_{addon.slug}"
|
||||||
|
return await self._api_host.advanced_logs(request, *args, **kwargs)
|
||||||
|
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/addons/{addon}/logs", get_addon_logs),
|
||||||
|
web.get(
|
||||||
|
"/addons/{addon}/logs/follow",
|
||||||
|
partial(get_addon_logs, follow=True),
|
||||||
|
),
|
||||||
|
web.get("/addons/{addon}/logs/boots/{bootid}", get_addon_logs),
|
||||||
|
web.get(
|
||||||
|
"/addons/{addon}/logs/boots/{bootid}/follow",
|
||||||
|
partial(get_addon_logs, follow=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy routing to support requests for not installed addons
|
||||||
|
api_store = APIStore()
|
||||||
|
api_store.coresys = self.coresys
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def addons_addon_info(request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Route to store if info requested for not installed addon."""
|
||||||
|
try:
|
||||||
|
return await api_addons.info(request)
|
||||||
|
except APIAddonNotInstalled:
|
||||||
|
# Route to store/{addon}/info but add missing fields
|
||||||
|
return dict(
|
||||||
|
await api_store.addons_addon_info_wrapped(request),
|
||||||
|
state=AddonState.UNKNOWN,
|
||||||
|
options=self.sys_addons.store[request.match_info["addon"]].options,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.webapp.add_routes([web.get("/addons/{addon}/info", addons_addon_info)])
|
||||||
|
|
||||||
def _register_ingress(self) -> None:
|
def _register_ingress(self) -> None:
|
||||||
"""Register Ingress functions."""
|
"""Register Ingress functions."""
|
||||||
api_ingress = APIIngress()
|
api_ingress = APIIngress()
|
||||||
@@ -372,30 +575,30 @@ class RestAPI(CoreSysAttributes):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _register_snapshots(self) -> None:
|
def _register_backups(self) -> None:
|
||||||
"""Register snapshots functions."""
|
"""Register backups functions."""
|
||||||
api_snapshots = APISnapshots()
|
api_backups = APIBackups()
|
||||||
api_snapshots.coresys = self.coresys
|
api_backups.coresys = self.coresys
|
||||||
|
|
||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
web.get("/snapshots", api_snapshots.list),
|
web.get("/backups", api_backups.list),
|
||||||
web.post("/snapshots/reload", api_snapshots.reload),
|
web.get("/backups/info", api_backups.info),
|
||||||
web.post("/snapshots/new/full", api_snapshots.snapshot_full),
|
web.post("/backups/options", api_backups.options),
|
||||||
web.post("/snapshots/new/partial", api_snapshots.snapshot_partial),
|
web.post("/backups/reload", api_backups.reload),
|
||||||
web.post("/snapshots/new/upload", api_snapshots.upload),
|
web.post("/backups/freeze", api_backups.freeze),
|
||||||
web.get("/snapshots/{snapshot}/info", api_snapshots.info),
|
web.post("/backups/thaw", api_backups.thaw),
|
||||||
web.delete("/snapshots/{snapshot}", api_snapshots.remove),
|
web.post("/backups/new/full", api_backups.backup_full),
|
||||||
|
web.post("/backups/new/partial", api_backups.backup_partial),
|
||||||
|
web.post("/backups/new/upload", api_backups.upload),
|
||||||
|
web.get("/backups/{slug}/info", api_backups.backup_info),
|
||||||
|
web.delete("/backups/{slug}", api_backups.remove),
|
||||||
|
web.post("/backups/{slug}/restore/full", api_backups.restore_full),
|
||||||
web.post(
|
web.post(
|
||||||
"/snapshots/{snapshot}/restore/full", api_snapshots.restore_full
|
"/backups/{slug}/restore/partial",
|
||||||
|
api_backups.restore_partial,
|
||||||
),
|
),
|
||||||
web.post(
|
web.get("/backups/{slug}/download", api_backups.download),
|
||||||
"/snapshots/{snapshot}/restore/partial",
|
|
||||||
api_snapshots.restore_partial,
|
|
||||||
),
|
|
||||||
web.get("/snapshots/{snapshot}/download", api_snapshots.download),
|
|
||||||
# Old, remove at end of 2020
|
|
||||||
web.post("/snapshots/{snapshot}/remove", api_snapshots.remove),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -436,7 +639,6 @@ class RestAPI(CoreSysAttributes):
|
|||||||
[
|
[
|
||||||
web.get("/dns/info", api_dns.info),
|
web.get("/dns/info", api_dns.info),
|
||||||
web.get("/dns/stats", api_dns.stats),
|
web.get("/dns/stats", api_dns.stats),
|
||||||
web.get("/dns/logs", api_dns.logs),
|
|
||||||
web.post("/dns/update", api_dns.update),
|
web.post("/dns/update", api_dns.update),
|
||||||
web.post("/dns/options", api_dns.options),
|
web.post("/dns/options", api_dns.options),
|
||||||
web.post("/dns/restart", api_dns.restart),
|
web.post("/dns/restart", api_dns.restart),
|
||||||
@@ -444,6 +646,8 @@ class RestAPI(CoreSysAttributes):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._register_advanced_logs("/dns", "hassio_dns")
|
||||||
|
|
||||||
def _register_audio(self) -> None:
|
def _register_audio(self) -> None:
|
||||||
"""Register Audio functions."""
|
"""Register Audio functions."""
|
||||||
api_audio = APIAudio()
|
api_audio = APIAudio()
|
||||||
@@ -453,7 +657,6 @@ class RestAPI(CoreSysAttributes):
|
|||||||
[
|
[
|
||||||
web.get("/audio/info", api_audio.info),
|
web.get("/audio/info", api_audio.info),
|
||||||
web.get("/audio/stats", api_audio.stats),
|
web.get("/audio/stats", api_audio.stats),
|
||||||
web.get("/audio/logs", api_audio.logs),
|
|
||||||
web.post("/audio/update", api_audio.update),
|
web.post("/audio/update", api_audio.update),
|
||||||
web.post("/audio/restart", api_audio.restart),
|
web.post("/audio/restart", api_audio.restart),
|
||||||
web.post("/audio/reload", api_audio.reload),
|
web.post("/audio/reload", api_audio.reload),
|
||||||
@@ -466,6 +669,86 @@ class RestAPI(CoreSysAttributes):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._register_advanced_logs("/audio", "hassio_audio")
|
||||||
|
|
||||||
|
def _register_mounts(self) -> None:
|
||||||
|
"""Register mounts endpoints."""
|
||||||
|
api_mounts = APIMounts()
|
||||||
|
api_mounts.coresys = self.coresys
|
||||||
|
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/mounts", api_mounts.info),
|
||||||
|
web.post("/mounts/options", api_mounts.options),
|
||||||
|
web.post("/mounts", api_mounts.create_mount),
|
||||||
|
web.put("/mounts/{mount}", api_mounts.update_mount),
|
||||||
|
web.delete("/mounts/{mount}", api_mounts.delete_mount),
|
||||||
|
web.post("/mounts/{mount}/reload", api_mounts.reload_mount),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _register_store(self) -> None:
|
||||||
|
"""Register store endpoints."""
|
||||||
|
api_store = APIStore()
|
||||||
|
api_store.coresys = self.coresys
|
||||||
|
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/store", api_store.store_info),
|
||||||
|
web.get("/store/addons", api_store.addons_list),
|
||||||
|
web.get("/store/addons/{addon}", api_store.addons_addon_info),
|
||||||
|
web.get("/store/addons/{addon}/icon", api_store.addons_addon_icon),
|
||||||
|
web.get("/store/addons/{addon}/logo", api_store.addons_addon_logo),
|
||||||
|
web.get(
|
||||||
|
"/store/addons/{addon}/changelog", api_store.addons_addon_changelog
|
||||||
|
),
|
||||||
|
web.get(
|
||||||
|
"/store/addons/{addon}/documentation",
|
||||||
|
api_store.addons_addon_documentation,
|
||||||
|
),
|
||||||
|
web.post(
|
||||||
|
"/store/addons/{addon}/install", api_store.addons_addon_install
|
||||||
|
),
|
||||||
|
web.post(
|
||||||
|
"/store/addons/{addon}/install/{version}",
|
||||||
|
api_store.addons_addon_install,
|
||||||
|
),
|
||||||
|
web.post("/store/addons/{addon}/update", api_store.addons_addon_update),
|
||||||
|
web.post(
|
||||||
|
"/store/addons/{addon}/update/{version}",
|
||||||
|
api_store.addons_addon_update,
|
||||||
|
),
|
||||||
|
# Must be below others since it has a wildcard in resource path
|
||||||
|
web.get("/store/addons/{addon}/{version}", api_store.addons_addon_info),
|
||||||
|
web.post("/store/reload", api_store.reload),
|
||||||
|
web.get("/store/repositories", api_store.repositories_list),
|
||||||
|
web.get(
|
||||||
|
"/store/repositories/{repository}",
|
||||||
|
api_store.repositories_repository_info,
|
||||||
|
),
|
||||||
|
web.post("/store/repositories", api_store.add_repository),
|
||||||
|
web.delete(
|
||||||
|
"/store/repositories/{repository}", api_store.remove_repository
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reroute from legacy
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.post("/addons/reload", api_store.reload),
|
||||||
|
web.post("/addons/{addon}/install", api_store.addons_addon_install),
|
||||||
|
web.post("/addons/{addon}/update", api_store.addons_addon_update),
|
||||||
|
web.get("/addons/{addon}/icon", api_store.addons_addon_icon),
|
||||||
|
web.get("/addons/{addon}/logo", api_store.addons_addon_logo),
|
||||||
|
web.get("/addons/{addon}/changelog", api_store.addons_addon_changelog),
|
||||||
|
web.get(
|
||||||
|
"/addons/{addon}/documentation",
|
||||||
|
api_store.addons_addon_documentation,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def _register_panel(self) -> None:
|
def _register_panel(self) -> None:
|
||||||
"""Register panel for Home Assistant."""
|
"""Register panel for Home Assistant."""
|
||||||
panel_dir = Path(__file__).parent.joinpath("panel")
|
panel_dir = Path(__file__).parent.joinpath("panel")
|
||||||
@@ -488,9 +771,7 @@ class RestAPI(CoreSysAttributes):
|
|||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Run RESTful API webserver."""
|
"""Run RESTful API webserver."""
|
||||||
await self._runner.setup()
|
await self._runner.setup()
|
||||||
self._site = web.TCPSite(
|
self._site = web.TCPSite(self._runner, host="0.0.0.0", port=80)
|
||||||
self._runner, host="0.0.0.0", port=80, shutdown_timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._site.start()
|
await self._site.start()
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Dict, List
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from voluptuous.humanize import humanize_error
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
from ..addons import AnyAddon
|
|
||||||
from ..addons.addon import Addon
|
from ..addons.addon import Addon
|
||||||
|
from ..addons.manager import AnyAddon
|
||||||
from ..addons.utils import rating_security
|
from ..addons.utils import rating_security
|
||||||
from ..const import (
|
from ..const import (
|
||||||
ATTR_ADDONS,
|
ATTR_ADDONS,
|
||||||
@@ -45,6 +47,7 @@ from ..const import (
|
|||||||
ATTR_HOST_IPC,
|
ATTR_HOST_IPC,
|
||||||
ATTR_HOST_NETWORK,
|
ATTR_HOST_NETWORK,
|
||||||
ATTR_HOST_PID,
|
ATTR_HOST_PID,
|
||||||
|
ATTR_HOST_UTS,
|
||||||
ATTR_HOSTNAME,
|
ATTR_HOSTNAME,
|
||||||
ATTR_ICON,
|
ATTR_ICON,
|
||||||
ATTR_INGRESS,
|
ATTR_INGRESS,
|
||||||
@@ -52,13 +55,11 @@ from ..const import (
|
|||||||
ATTR_INGRESS_PANEL,
|
ATTR_INGRESS_PANEL,
|
||||||
ATTR_INGRESS_PORT,
|
ATTR_INGRESS_PORT,
|
||||||
ATTR_INGRESS_URL,
|
ATTR_INGRESS_URL,
|
||||||
ATTR_INSTALLED,
|
|
||||||
ATTR_IP_ADDRESS,
|
ATTR_IP_ADDRESS,
|
||||||
ATTR_KERNEL_MODULES,
|
ATTR_KERNEL_MODULES,
|
||||||
ATTR_LOGO,
|
ATTR_LOGO,
|
||||||
ATTR_LONG_DESCRIPTION,
|
ATTR_LONG_DESCRIPTION,
|
||||||
ATTR_MACHINE,
|
ATTR_MACHINE,
|
||||||
ATTR_MAINTAINER,
|
|
||||||
ATTR_MEMORY_LIMIT,
|
ATTR_MEMORY_LIMIT,
|
||||||
ATTR_MEMORY_PERCENT,
|
ATTR_MEMORY_PERCENT,
|
||||||
ATTR_MEMORY_USAGE,
|
ATTR_MEMORY_USAGE,
|
||||||
@@ -71,17 +72,20 @@ from ..const import (
|
|||||||
ATTR_OPTIONS,
|
ATTR_OPTIONS,
|
||||||
ATTR_PRIVILEGED,
|
ATTR_PRIVILEGED,
|
||||||
ATTR_PROTECTED,
|
ATTR_PROTECTED,
|
||||||
|
ATTR_PWNED,
|
||||||
ATTR_RATING,
|
ATTR_RATING,
|
||||||
ATTR_REPOSITORIES,
|
|
||||||
ATTR_REPOSITORY,
|
ATTR_REPOSITORY,
|
||||||
ATTR_SCHEMA,
|
ATTR_SCHEMA,
|
||||||
ATTR_SERVICES,
|
ATTR_SERVICES,
|
||||||
ATTR_SLUG,
|
ATTR_SLUG,
|
||||||
ATTR_SOURCE,
|
|
||||||
ATTR_STAGE,
|
ATTR_STAGE,
|
||||||
ATTR_STARTUP,
|
ATTR_STARTUP,
|
||||||
ATTR_STATE,
|
ATTR_STATE,
|
||||||
ATTR_STDIN,
|
ATTR_STDIN,
|
||||||
|
ATTR_SYSTEM_MANAGED,
|
||||||
|
ATTR_SYSTEM_MANAGED_CONFIG_ENTRY,
|
||||||
|
ATTR_TRANSLATIONS,
|
||||||
|
ATTR_UART,
|
||||||
ATTR_UDEV,
|
ATTR_UDEV,
|
||||||
ATTR_UPDATE_AVAILABLE,
|
ATTR_UPDATE_AVAILABLE,
|
||||||
ATTR_URL,
|
ATTR_URL,
|
||||||
@@ -92,22 +96,26 @@ from ..const import (
|
|||||||
ATTR_VIDEO,
|
ATTR_VIDEO,
|
||||||
ATTR_WATCHDOG,
|
ATTR_WATCHDOG,
|
||||||
ATTR_WEBUI,
|
ATTR_WEBUI,
|
||||||
CONTENT_TYPE_BINARY,
|
|
||||||
CONTENT_TYPE_PNG,
|
|
||||||
CONTENT_TYPE_TEXT,
|
|
||||||
REQUEST_FROM,
|
REQUEST_FROM,
|
||||||
AddonBoot,
|
AddonBoot,
|
||||||
AddonState,
|
AddonBootConfig,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..docker.stats import DockerStats
|
from ..docker.stats import DockerStats
|
||||||
from ..exceptions import APIError
|
from ..exceptions import (
|
||||||
|
APIAddonNotInstalled,
|
||||||
|
APIError,
|
||||||
|
APIForbidden,
|
||||||
|
PwnedError,
|
||||||
|
PwnedSecret,
|
||||||
|
)
|
||||||
from ..validate import docker_ports
|
from ..validate import docker_ports
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .const import ATTR_BOOT_CONFIG, ATTR_REMOVE_CONFIG, ATTR_SIGNED
|
||||||
|
from .utils import api_process, api_validate, json_loads
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
|
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): str})
|
||||||
|
|
||||||
# pylint: disable=no-value-for-parameter
|
# pylint: disable=no-value-for-parameter
|
||||||
SCHEMA_OPTIONS = vol.Schema(
|
SCHEMA_OPTIONS = vol.Schema(
|
||||||
@@ -115,22 +123,33 @@ SCHEMA_OPTIONS = vol.Schema(
|
|||||||
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
|
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
|
||||||
vol.Optional(ATTR_NETWORK): vol.Maybe(docker_ports),
|
vol.Optional(ATTR_NETWORK): vol.Maybe(docker_ports),
|
||||||
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
|
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
|
||||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
|
||||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||||
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
|
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
|
||||||
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=no-value-for-parameter
|
SCHEMA_SYS_OPTIONS = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_SYSTEM_MANAGED): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_SYSTEM_MANAGED_CONFIG_ENTRY): vol.Maybe(str),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
|
SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
|
||||||
|
|
||||||
|
SCHEMA_UNINSTALL = vol.Schema(
|
||||||
|
{vol.Optional(ATTR_REMOVE_CONFIG, default=False): vol.Boolean()}
|
||||||
|
)
|
||||||
|
# pylint: enable=no-value-for-parameter
|
||||||
|
|
||||||
|
|
||||||
class APIAddons(CoreSysAttributes):
|
class APIAddons(CoreSysAttributes):
|
||||||
"""Handle RESTful API for add-on functions."""
|
"""Handle RESTful API for add-on functions."""
|
||||||
|
|
||||||
def _extract_addon(self, request: web.Request) -> AnyAddon:
|
def get_addon_for_request(self, request: web.Request) -> Addon:
|
||||||
"""Return addon, throw an exception it it doesn't exist."""
|
"""Return addon, throw an exception if it doesn't exist."""
|
||||||
addon_slug: str = request.match_info.get("addon")
|
addon_slug: str = request.match_info.get("addon")
|
||||||
|
|
||||||
# Lookup itself
|
# Lookup itself
|
||||||
@@ -143,17 +162,13 @@ class APIAddons(CoreSysAttributes):
|
|||||||
addon = self.sys_addons.get(addon_slug)
|
addon = self.sys_addons.get(addon_slug)
|
||||||
if not addon:
|
if not addon:
|
||||||
raise APIError(f"Addon {addon_slug} does not exist")
|
raise APIError(f"Addon {addon_slug} does not exist")
|
||||||
|
|
||||||
return addon
|
|
||||||
|
|
||||||
def _extract_addon_installed(self, request: web.Request) -> Addon:
|
|
||||||
addon = self._extract_addon(request)
|
|
||||||
if not isinstance(addon, Addon) or not addon.is_installed:
|
if not isinstance(addon, Addon) or not addon.is_installed:
|
||||||
raise APIError("Addon is not installed")
|
raise APIAddonNotInstalled("Addon is not installed")
|
||||||
|
|
||||||
return addon
|
return addon
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def list(self, request: web.Request) -> Dict[str, Any]:
|
async def list(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return all add-ons or repositories."""
|
"""Return all add-ons or repositories."""
|
||||||
data_addons = [
|
data_addons = [
|
||||||
{
|
{
|
||||||
@@ -162,44 +177,33 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_DESCRIPTON: addon.description,
|
ATTR_DESCRIPTON: addon.description,
|
||||||
ATTR_ADVANCED: addon.advanced,
|
ATTR_ADVANCED: addon.advanced,
|
||||||
ATTR_STAGE: addon.stage,
|
ATTR_STAGE: addon.stage,
|
||||||
ATTR_VERSION: addon.version if addon.is_installed else None,
|
ATTR_VERSION: addon.version,
|
||||||
ATTR_VERSION_LATEST: addon.latest_version,
|
ATTR_VERSION_LATEST: addon.latest_version,
|
||||||
ATTR_UPDATE_AVAILABLE: addon.need_update
|
ATTR_UPDATE_AVAILABLE: addon.need_update,
|
||||||
if addon.is_installed
|
|
||||||
else False,
|
|
||||||
ATTR_INSTALLED: addon.is_installed,
|
|
||||||
ATTR_AVAILABLE: addon.available,
|
ATTR_AVAILABLE: addon.available,
|
||||||
ATTR_DETACHED: addon.is_detached,
|
ATTR_DETACHED: addon.is_detached,
|
||||||
|
ATTR_HOMEASSISTANT: addon.homeassistant_version,
|
||||||
|
ATTR_STATE: addon.state,
|
||||||
ATTR_REPOSITORY: addon.repository,
|
ATTR_REPOSITORY: addon.repository,
|
||||||
ATTR_BUILD: addon.need_build,
|
ATTR_BUILD: addon.need_build,
|
||||||
ATTR_URL: addon.url,
|
ATTR_URL: addon.url,
|
||||||
ATTR_ICON: addon.with_icon,
|
ATTR_ICON: addon.with_icon,
|
||||||
ATTR_LOGO: addon.with_logo,
|
ATTR_LOGO: addon.with_logo,
|
||||||
|
ATTR_SYSTEM_MANAGED: addon.system_managed,
|
||||||
}
|
}
|
||||||
for addon in self.sys_addons.all
|
for addon in self.sys_addons.installed
|
||||||
]
|
]
|
||||||
|
|
||||||
data_repositories = [
|
return {ATTR_ADDONS: data_addons}
|
||||||
{
|
|
||||||
ATTR_SLUG: repository.slug,
|
|
||||||
ATTR_NAME: repository.name,
|
|
||||||
ATTR_SOURCE: repository.source,
|
|
||||||
ATTR_URL: repository.url,
|
|
||||||
ATTR_MAINTAINER: repository.maintainer,
|
|
||||||
}
|
|
||||||
for repository in self.sys_store.all
|
|
||||||
]
|
|
||||||
return {ATTR_ADDONS: data_addons, ATTR_REPOSITORIES: data_repositories}
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def reload(self, request: web.Request) -> None:
|
async def reload(self, request: web.Request) -> None:
|
||||||
"""Reload all add-on data from store."""
|
"""Reload all add-on data from store."""
|
||||||
await asyncio.shield(self.sys_store.reload())
|
await asyncio.shield(self.sys_store.reload())
|
||||||
|
|
||||||
@api_process
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
|
||||||
"""Return add-on information."""
|
"""Return add-on information."""
|
||||||
addon: AnyAddon = self._extract_addon(request)
|
addon: AnyAddon = self.get_addon_for_request(request)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
ATTR_NAME: addon.name,
|
ATTR_NAME: addon.name,
|
||||||
@@ -210,13 +214,11 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_LONG_DESCRIPTION: addon.long_description,
|
ATTR_LONG_DESCRIPTION: addon.long_description,
|
||||||
ATTR_ADVANCED: addon.advanced,
|
ATTR_ADVANCED: addon.advanced,
|
||||||
ATTR_STAGE: addon.stage,
|
ATTR_STAGE: addon.stage,
|
||||||
ATTR_AUTO_UPDATE: None,
|
|
||||||
ATTR_REPOSITORY: addon.repository,
|
ATTR_REPOSITORY: addon.repository,
|
||||||
ATTR_VERSION: None,
|
|
||||||
ATTR_VERSION_LATEST: addon.latest_version,
|
ATTR_VERSION_LATEST: addon.latest_version,
|
||||||
ATTR_UPDATE_AVAILABLE: False,
|
|
||||||
ATTR_PROTECTED: addon.protected,
|
ATTR_PROTECTED: addon.protected,
|
||||||
ATTR_RATING: rating_security(addon),
|
ATTR_RATING: rating_security(addon),
|
||||||
|
ATTR_BOOT_CONFIG: addon.boot_config,
|
||||||
ATTR_BOOT: addon.boot,
|
ATTR_BOOT: addon.boot,
|
||||||
ATTR_OPTIONS: addon.options,
|
ATTR_OPTIONS: addon.options,
|
||||||
ATTR_SCHEMA: addon.schema_ui,
|
ATTR_SCHEMA: addon.schema_ui,
|
||||||
@@ -224,7 +226,6 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_MACHINE: addon.supported_machine,
|
ATTR_MACHINE: addon.supported_machine,
|
||||||
ATTR_HOMEASSISTANT: addon.homeassistant_version,
|
ATTR_HOMEASSISTANT: addon.homeassistant_version,
|
||||||
ATTR_URL: addon.url,
|
ATTR_URL: addon.url,
|
||||||
ATTR_STATE: AddonState.UNKNOWN,
|
|
||||||
ATTR_DETACHED: addon.is_detached,
|
ATTR_DETACHED: addon.is_detached,
|
||||||
ATTR_AVAILABLE: addon.available,
|
ATTR_AVAILABLE: addon.available,
|
||||||
ATTR_BUILD: addon.need_build,
|
ATTR_BUILD: addon.need_build,
|
||||||
@@ -233,46 +234,35 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_HOST_NETWORK: addon.host_network,
|
ATTR_HOST_NETWORK: addon.host_network,
|
||||||
ATTR_HOST_PID: addon.host_pid,
|
ATTR_HOST_PID: addon.host_pid,
|
||||||
ATTR_HOST_IPC: addon.host_ipc,
|
ATTR_HOST_IPC: addon.host_ipc,
|
||||||
|
ATTR_HOST_UTS: addon.host_uts,
|
||||||
ATTR_HOST_DBUS: addon.host_dbus,
|
ATTR_HOST_DBUS: addon.host_dbus,
|
||||||
ATTR_PRIVILEGED: addon.privileged,
|
ATTR_PRIVILEGED: addon.privileged,
|
||||||
ATTR_FULL_ACCESS: addon.with_full_access,
|
ATTR_FULL_ACCESS: addon.with_full_access,
|
||||||
ATTR_APPARMOR: addon.apparmor,
|
ATTR_APPARMOR: addon.apparmor,
|
||||||
ATTR_DEVICES: _pretty_devices(addon),
|
|
||||||
ATTR_ICON: addon.with_icon,
|
ATTR_ICON: addon.with_icon,
|
||||||
ATTR_LOGO: addon.with_logo,
|
ATTR_LOGO: addon.with_logo,
|
||||||
ATTR_CHANGELOG: addon.with_changelog,
|
ATTR_CHANGELOG: addon.with_changelog,
|
||||||
ATTR_DOCUMENTATION: addon.with_documentation,
|
ATTR_DOCUMENTATION: addon.with_documentation,
|
||||||
ATTR_STDIN: addon.with_stdin,
|
ATTR_STDIN: addon.with_stdin,
|
||||||
ATTR_WEBUI: None,
|
|
||||||
ATTR_HASSIO_API: addon.access_hassio_api,
|
ATTR_HASSIO_API: addon.access_hassio_api,
|
||||||
ATTR_HASSIO_ROLE: addon.hassio_role,
|
ATTR_HASSIO_ROLE: addon.hassio_role,
|
||||||
ATTR_AUTH_API: addon.access_auth_api,
|
ATTR_AUTH_API: addon.access_auth_api,
|
||||||
ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api,
|
ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api,
|
||||||
ATTR_GPIO: addon.with_gpio,
|
ATTR_GPIO: addon.with_gpio,
|
||||||
ATTR_USB: addon.with_usb,
|
ATTR_USB: addon.with_usb,
|
||||||
|
ATTR_UART: addon.with_uart,
|
||||||
ATTR_KERNEL_MODULES: addon.with_kernel_modules,
|
ATTR_KERNEL_MODULES: addon.with_kernel_modules,
|
||||||
ATTR_DEVICETREE: addon.with_devicetree,
|
ATTR_DEVICETREE: addon.with_devicetree,
|
||||||
ATTR_UDEV: addon.with_udev,
|
ATTR_UDEV: addon.with_udev,
|
||||||
ATTR_DOCKER_API: addon.access_docker_api,
|
ATTR_DOCKER_API: addon.access_docker_api,
|
||||||
ATTR_VIDEO: addon.with_video,
|
ATTR_VIDEO: addon.with_video,
|
||||||
ATTR_AUDIO: addon.with_audio,
|
ATTR_AUDIO: addon.with_audio,
|
||||||
ATTR_AUDIO_INPUT: None,
|
|
||||||
ATTR_AUDIO_OUTPUT: None,
|
|
||||||
ATTR_STARTUP: addon.startup,
|
ATTR_STARTUP: addon.startup,
|
||||||
ATTR_SERVICES: _pretty_services(addon),
|
ATTR_SERVICES: _pretty_services(addon),
|
||||||
ATTR_DISCOVERY: addon.discovery,
|
ATTR_DISCOVERY: addon.discovery,
|
||||||
ATTR_IP_ADDRESS: None,
|
ATTR_TRANSLATIONS: addon.translations,
|
||||||
ATTR_INGRESS: addon.with_ingress,
|
ATTR_INGRESS: addon.with_ingress,
|
||||||
ATTR_INGRESS_ENTRY: None,
|
ATTR_SIGNED: addon.signed,
|
||||||
ATTR_INGRESS_URL: None,
|
|
||||||
ATTR_INGRESS_PORT: None,
|
|
||||||
ATTR_INGRESS_PANEL: None,
|
|
||||||
ATTR_WATCHDOG: None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if isinstance(addon, Addon) and addon.is_installed:
|
|
||||||
data.update(
|
|
||||||
{
|
|
||||||
ATTR_STATE: addon.state,
|
ATTR_STATE: addon.state,
|
||||||
ATTR_WEBUI: addon.webui,
|
ATTR_WEBUI: addon.webui,
|
||||||
ATTR_INGRESS_ENTRY: addon.ingress_entry,
|
ATTR_INGRESS_ENTRY: addon.ingress_entry,
|
||||||
@@ -286,22 +276,25 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_VERSION: addon.version,
|
ATTR_VERSION: addon.version,
|
||||||
ATTR_UPDATE_AVAILABLE: addon.need_update,
|
ATTR_UPDATE_AVAILABLE: addon.need_update,
|
||||||
ATTR_WATCHDOG: addon.watchdog,
|
ATTR_WATCHDOG: addon.watchdog,
|
||||||
|
ATTR_DEVICES: addon.static_devices
|
||||||
|
+ [device.path for device in addon.devices],
|
||||||
|
ATTR_SYSTEM_MANAGED: addon.system_managed,
|
||||||
|
ATTR_SYSTEM_MANAGED_CONFIG_ENTRY: addon.system_managed_config_entry,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def options(self, request: web.Request) -> None:
|
async def options(self, request: web.Request) -> None:
|
||||||
"""Store user options for add-on."""
|
"""Store user options for add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
|
|
||||||
# Update secrets for validation
|
# Update secrets for validation
|
||||||
await self.sys_homeassistant.secrets.reload()
|
await self.sys_homeassistant.secrets.reload()
|
||||||
|
|
||||||
# Extend schema with add-on specific validation
|
# Extend schema with add-on specific validation
|
||||||
addon_schema = SCHEMA_OPTIONS.extend(
|
addon_schema = SCHEMA_OPTIONS.extend(
|
||||||
{vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema)}
|
{vol.Optional(ATTR_OPTIONS): vol.Maybe(addon.schema)}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate/Process Body
|
# Validate/Process Body
|
||||||
@@ -309,6 +302,10 @@ class APIAddons(CoreSysAttributes):
|
|||||||
if ATTR_OPTIONS in body:
|
if ATTR_OPTIONS in body:
|
||||||
addon.options = body[ATTR_OPTIONS]
|
addon.options = body[ATTR_OPTIONS]
|
||||||
if ATTR_BOOT in body:
|
if ATTR_BOOT in body:
|
||||||
|
if addon.boot_config == AddonBootConfig.MANUAL_ONLY:
|
||||||
|
raise APIError(
|
||||||
|
f"Addon {addon.slug} boot option is set to {addon.boot_config} so it cannot be changed"
|
||||||
|
)
|
||||||
addon.boot = body[ATTR_BOOT]
|
addon.boot = body[ATTR_BOOT]
|
||||||
if ATTR_AUTO_UPDATE in body:
|
if ATTR_AUTO_UPDATE in body:
|
||||||
addon.auto_update = body[ATTR_AUTO_UPDATE]
|
addon.auto_update = body[ATTR_AUTO_UPDATE]
|
||||||
@@ -326,24 +323,79 @@ class APIAddons(CoreSysAttributes):
|
|||||||
|
|
||||||
addon.save_persist()
|
addon.save_persist()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def sys_options(self, request: web.Request) -> None:
|
||||||
|
"""Store system options for an add-on."""
|
||||||
|
addon = self.get_addon_for_request(request)
|
||||||
|
|
||||||
|
# Validate/Process Body
|
||||||
|
body = await api_validate(SCHEMA_SYS_OPTIONS, request)
|
||||||
|
if ATTR_SYSTEM_MANAGED in body:
|
||||||
|
addon.system_managed = body[ATTR_SYSTEM_MANAGED]
|
||||||
|
if ATTR_SYSTEM_MANAGED_CONFIG_ENTRY in body:
|
||||||
|
addon.system_managed_config_entry = body[ATTR_SYSTEM_MANAGED_CONFIG_ENTRY]
|
||||||
|
|
||||||
|
addon.save_persist()
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def options_validate(self, request: web.Request) -> None:
|
async def options_validate(self, request: web.Request) -> None:
|
||||||
"""Validate user options for add-on."""
|
"""Validate user options for add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
data = {ATTR_MESSAGE: "", ATTR_VALID: True}
|
data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False}
|
||||||
|
|
||||||
|
options = await request.json(loads=json_loads) or addon.options
|
||||||
|
|
||||||
|
# Validate config
|
||||||
|
options_schema = addon.schema
|
||||||
try:
|
try:
|
||||||
addon.schema(addon.options)
|
options_schema.validate(options)
|
||||||
except vol.Invalid as ex:
|
except vol.Invalid as ex:
|
||||||
data[ATTR_MESSAGE] = humanize_error(addon.options, ex)
|
data[ATTR_MESSAGE] = humanize_error(options, ex)
|
||||||
data[ATTR_VALID] = False
|
data[ATTR_VALID] = False
|
||||||
|
|
||||||
|
if not self.sys_security.pwned:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Pwned check
|
||||||
|
for secret in options_schema.pwned:
|
||||||
|
try:
|
||||||
|
await self.sys_security.verify_secret(secret)
|
||||||
|
continue
|
||||||
|
except PwnedSecret:
|
||||||
|
data[ATTR_PWNED] = True
|
||||||
|
except PwnedError:
|
||||||
|
data[ATTR_PWNED] = None
|
||||||
|
break
|
||||||
|
|
||||||
|
if self.sys_security.force and data[ATTR_PWNED] in (None, True):
|
||||||
|
data[ATTR_VALID] = False
|
||||||
|
if data[ATTR_PWNED] is None:
|
||||||
|
data[ATTR_MESSAGE] = "Error happening on pwned secrets check!"
|
||||||
|
else:
|
||||||
|
data[ATTR_MESSAGE] = "Add-on uses pwned secrets!"
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def options_config(self, request: web.Request) -> None:
|
||||||
|
"""Validate user options for add-on."""
|
||||||
|
slug: str = request.match_info.get("addon")
|
||||||
|
if slug != "self":
|
||||||
|
raise APIForbidden("This can be only read by the Add-on itself!")
|
||||||
|
addon = self.get_addon_for_request(request)
|
||||||
|
|
||||||
|
# Lookup/reload secrets
|
||||||
|
await self.sys_homeassistant.secrets.reload()
|
||||||
|
try:
|
||||||
|
return addon.schema.validate(addon.options)
|
||||||
|
except vol.Invalid:
|
||||||
|
raise APIError("Invalid configuration data for the add-on") from None
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def security(self, request: web.Request) -> None:
|
async def security(self, request: web.Request) -> None:
|
||||||
"""Store security options for add-on."""
|
"""Store security options for add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
body: Dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
|
body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
|
||||||
|
|
||||||
if ATTR_PROTECTED in body:
|
if ATTR_PROTECTED in body:
|
||||||
_LOGGER.warning("Changing protected flag for %s!", addon.slug)
|
_LOGGER.warning("Changing protected flag for %s!", addon.slug)
|
||||||
@@ -352,9 +404,9 @@ class APIAddons(CoreSysAttributes):
|
|||||||
addon.save_persist()
|
addon.save_persist()
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return resource information."""
|
"""Return resource information."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
|
|
||||||
stats: DockerStats = await addon.stats()
|
stats: DockerStats = await addon.stats()
|
||||||
|
|
||||||
@@ -370,97 +422,47 @@ class APIAddons(CoreSysAttributes):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def install(self, request: web.Request) -> Awaitable[None]:
|
async def uninstall(self, request: web.Request) -> Awaitable[None]:
|
||||||
"""Install add-on."""
|
|
||||||
addon = self._extract_addon(request)
|
|
||||||
return asyncio.shield(addon.install())
|
|
||||||
|
|
||||||
@api_process
|
|
||||||
def uninstall(self, request: web.Request) -> Awaitable[None]:
|
|
||||||
"""Uninstall add-on."""
|
"""Uninstall add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
return asyncio.shield(addon.uninstall())
|
body: dict[str, Any] = await api_validate(SCHEMA_UNINSTALL, request)
|
||||||
|
return await asyncio.shield(
|
||||||
|
self.sys_addons.uninstall(
|
||||||
|
addon.slug, remove_config=body[ATTR_REMOVE_CONFIG]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def start(self, request: web.Request) -> Awaitable[None]:
|
async def start(self, request: web.Request) -> None:
|
||||||
"""Start add-on."""
|
"""Start add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
return asyncio.shield(addon.start())
|
if start_task := await asyncio.shield(addon.start()):
|
||||||
|
await start_task
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def stop(self, request: web.Request) -> Awaitable[None]:
|
def stop(self, request: web.Request) -> Awaitable[None]:
|
||||||
"""Stop add-on."""
|
"""Stop add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
return asyncio.shield(addon.stop())
|
return asyncio.shield(addon.stop())
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def update(self, request: web.Request) -> Awaitable[None]:
|
async def restart(self, request: web.Request) -> None:
|
||||||
"""Update add-on."""
|
|
||||||
addon: Addon = self._extract_addon_installed(request)
|
|
||||||
return asyncio.shield(addon.update())
|
|
||||||
|
|
||||||
@api_process
|
|
||||||
def restart(self, request: web.Request) -> Awaitable[None]:
|
|
||||||
"""Restart add-on."""
|
"""Restart add-on."""
|
||||||
addon: Addon = self._extract_addon_installed(request)
|
addon: Addon = self.get_addon_for_request(request)
|
||||||
return asyncio.shield(addon.restart())
|
if start_task := await asyncio.shield(addon.restart()):
|
||||||
|
await start_task
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def rebuild(self, request: web.Request) -> Awaitable[None]:
|
async def rebuild(self, request: web.Request) -> None:
|
||||||
"""Rebuild local build add-on."""
|
"""Rebuild local build add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
return asyncio.shield(addon.rebuild())
|
if start_task := await asyncio.shield(self.sys_addons.rebuild(addon.slug)):
|
||||||
|
await start_task
|
||||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
|
||||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
|
||||||
"""Return logs from add-on."""
|
|
||||||
addon = self._extract_addon_installed(request)
|
|
||||||
return addon.logs()
|
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_PNG)
|
|
||||||
async def icon(self, request: web.Request) -> bytes:
|
|
||||||
"""Return icon from add-on."""
|
|
||||||
addon = self._extract_addon(request)
|
|
||||||
if not addon.with_icon:
|
|
||||||
raise APIError(f"No icon found for add-on {addon.slug}!")
|
|
||||||
|
|
||||||
with addon.path_icon.open("rb") as png:
|
|
||||||
return png.read()
|
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_PNG)
|
|
||||||
async def logo(self, request: web.Request) -> bytes:
|
|
||||||
"""Return logo from add-on."""
|
|
||||||
addon = self._extract_addon(request)
|
|
||||||
if not addon.with_logo:
|
|
||||||
raise APIError(f"No logo found for add-on {addon.slug}!")
|
|
||||||
|
|
||||||
with addon.path_logo.open("rb") as png:
|
|
||||||
return png.read()
|
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_TEXT)
|
|
||||||
async def changelog(self, request: web.Request) -> str:
|
|
||||||
"""Return changelog from add-on."""
|
|
||||||
addon = self._extract_addon(request)
|
|
||||||
if not addon.with_changelog:
|
|
||||||
raise APIError(f"No changelog found for add-on {addon.slug}!")
|
|
||||||
|
|
||||||
with addon.path_changelog.open("r") as changelog:
|
|
||||||
return changelog.read()
|
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_TEXT)
|
|
||||||
async def documentation(self, request: web.Request) -> str:
|
|
||||||
"""Return documentation from add-on."""
|
|
||||||
addon = self._extract_addon(request)
|
|
||||||
if not addon.with_documentation:
|
|
||||||
raise APIError(f"No documentation found for add-on {addon.slug}!")
|
|
||||||
|
|
||||||
with addon.path_documentation.open("r") as documentation:
|
|
||||||
return documentation.read()
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stdin(self, request: web.Request) -> None:
|
async def stdin(self, request: web.Request) -> None:
|
||||||
"""Write to stdin of add-on."""
|
"""Write to stdin of add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self.get_addon_for_request(request)
|
||||||
if not addon.with_stdin:
|
if not addon.with_stdin:
|
||||||
raise APIError(f"STDIN not supported the {addon.slug} add-on")
|
raise APIError(f"STDIN not supported the {addon.slug} add-on")
|
||||||
|
|
||||||
@@ -468,14 +470,6 @@ class APIAddons(CoreSysAttributes):
|
|||||||
await asyncio.shield(addon.write_stdin(data))
|
await asyncio.shield(addon.write_stdin(data))
|
||||||
|
|
||||||
|
|
||||||
def _pretty_devices(addon: AnyAddon) -> List[str]:
|
def _pretty_services(addon: Addon) -> list[str]:
|
||||||
"""Return a simplified device list."""
|
|
||||||
dev_list = addon.devices
|
|
||||||
if not dev_list:
|
|
||||||
return []
|
|
||||||
return [row.split(":")[0] for row in dev_list]
|
|
||||||
|
|
||||||
|
|
||||||
def _pretty_services(addon: AnyAddon) -> List[str]:
|
|
||||||
"""Return a simplified services role list."""
|
"""Return a simplified services role list."""
|
||||||
return [f"{name}:{access}" for name, access in addon.services_role.items()]
|
return [f"{name}:{access}" for name, access in addon.services_role.items()]
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Init file for Supervisor Audio RESTful API."""
|
"""Init file for Supervisor Audio RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from dataclasses import asdict
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import attr
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
@@ -29,13 +31,12 @@ from ..const import (
|
|||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
ATTR_VERSION_LATEST,
|
ATTR_VERSION_LATEST,
|
||||||
ATTR_VOLUME,
|
ATTR_VOLUME,
|
||||||
CONTENT_TYPE_BINARY,
|
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..exceptions import APIError
|
from ..exceptions import APIError
|
||||||
from ..host.sound import StreamType
|
from ..host.sound import StreamType
|
||||||
from ..validate import version_tag
|
from ..validate import version_tag
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,10 +57,10 @@ SCHEMA_MUTE = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
|
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): str})
|
||||||
|
|
||||||
SCHEMA_PROFILE = vol.Schema(
|
SCHEMA_PROFILE = vol.Schema(
|
||||||
{vol.Required(ATTR_CARD): vol.Coerce(str), vol.Required(ATTR_NAME): vol.Coerce(str)}
|
{vol.Required(ATTR_CARD): str, vol.Required(ATTR_NAME): str}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ class APIAudio(CoreSysAttributes):
|
|||||||
"""Handle RESTful API for Audio functions."""
|
"""Handle RESTful API for Audio functions."""
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return Audio information."""
|
"""Return Audio information."""
|
||||||
return {
|
return {
|
||||||
ATTR_VERSION: self.sys_plugins.audio.version,
|
ATTR_VERSION: self.sys_plugins.audio.version,
|
||||||
@@ -75,21 +76,17 @@ class APIAudio(CoreSysAttributes):
|
|||||||
ATTR_UPDATE_AVAILABLE: self.sys_plugins.audio.need_update,
|
ATTR_UPDATE_AVAILABLE: self.sys_plugins.audio.need_update,
|
||||||
ATTR_HOST: str(self.sys_docker.network.audio),
|
ATTR_HOST: str(self.sys_docker.network.audio),
|
||||||
ATTR_AUDIO: {
|
ATTR_AUDIO: {
|
||||||
ATTR_CARD: [attr.asdict(card) for card in self.sys_host.sound.cards],
|
ATTR_CARD: [asdict(card) for card in self.sys_host.sound.cards],
|
||||||
ATTR_INPUT: [
|
ATTR_INPUT: [asdict(stream) for stream in self.sys_host.sound.inputs],
|
||||||
attr.asdict(stream) for stream in self.sys_host.sound.inputs
|
ATTR_OUTPUT: [asdict(stream) for stream in self.sys_host.sound.outputs],
|
||||||
],
|
|
||||||
ATTR_OUTPUT: [
|
|
||||||
attr.asdict(stream) for stream in self.sys_host.sound.outputs
|
|
||||||
],
|
|
||||||
ATTR_APPLICATION: [
|
ATTR_APPLICATION: [
|
||||||
attr.asdict(stream) for stream in self.sys_host.sound.applications
|
asdict(stream) for stream in self.sys_host.sound.applications
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return resource information."""
|
"""Return resource information."""
|
||||||
stats = await self.sys_plugins.audio.stats()
|
stats = await self.sys_plugins.audio.stats()
|
||||||
|
|
||||||
@@ -114,11 +111,6 @@ class APIAudio(CoreSysAttributes):
|
|||||||
raise APIError(f"Version {version} is already in use")
|
raise APIError(f"Version {version} is already in use")
|
||||||
await asyncio.shield(self.sys_plugins.audio.update(version))
|
await asyncio.shield(self.sys_plugins.audio.update(version))
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
|
||||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
|
||||||
"""Return Audio Docker logs."""
|
|
||||||
return self.sys_plugins.audio.logs()
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def restart(self, request: web.Request) -> Awaitable[None]:
|
def restart(self, request: web.Request) -> Awaitable[None]:
|
||||||
"""Restart Audio plugin."""
|
"""Restart Audio plugin."""
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Init file for Supervisor auth/SSO RESTful API."""
|
"""Init file for Supervisor auth/SSO RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import BasicAuth, web
|
from aiohttp import BasicAuth, web
|
||||||
from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE
|
from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE
|
||||||
@@ -9,26 +10,34 @@ from aiohttp.web_exceptions import HTTPUnauthorized
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from ..addons.addon import Addon
|
from ..addons.addon import Addon
|
||||||
from ..const import (
|
from ..const import ATTR_NAME, ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM
|
||||||
ATTR_PASSWORD,
|
|
||||||
ATTR_USERNAME,
|
|
||||||
CONTENT_TYPE_JSON,
|
|
||||||
CONTENT_TYPE_URL,
|
|
||||||
REQUEST_FROM,
|
|
||||||
)
|
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..exceptions import APIForbidden
|
from ..exceptions import APIForbidden
|
||||||
|
from ..utils.json import json_loads
|
||||||
|
from .const import (
|
||||||
|
ATTR_GROUP_IDS,
|
||||||
|
ATTR_IS_ACTIVE,
|
||||||
|
ATTR_IS_OWNER,
|
||||||
|
ATTR_LOCAL_ONLY,
|
||||||
|
ATTR_USERS,
|
||||||
|
CONTENT_TYPE_JSON,
|
||||||
|
CONTENT_TYPE_URL,
|
||||||
|
)
|
||||||
from .utils import api_process, api_validate
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCHEMA_PASSWORD_RESET = vol.Schema(
|
SCHEMA_PASSWORD_RESET = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_USERNAME): vol.Coerce(str),
|
vol.Required(ATTR_USERNAME): str,
|
||||||
vol.Required(ATTR_PASSWORD): vol.Coerce(str),
|
vol.Required(ATTR_PASSWORD): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REALM_HEADER: dict[str, str] = {
|
||||||
|
WWW_AUTHENTICATE: 'Basic realm="Home Assistant Authentication"'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class APIAuth(CoreSysAttributes):
|
class APIAuth(CoreSysAttributes):
|
||||||
"""Handle RESTful API for auth functions."""
|
"""Handle RESTful API for auth functions."""
|
||||||
@@ -42,7 +51,7 @@ class APIAuth(CoreSysAttributes):
|
|||||||
return self.sys_auth.check_login(addon, auth.login, auth.password)
|
return self.sys_auth.check_login(addon, auth.login, auth.password)
|
||||||
|
|
||||||
def _process_dict(
|
def _process_dict(
|
||||||
self, request: web.Request, addon: Addon, data: Dict[str, str]
|
self, request: web.Request, addon: Addon, data: dict[str, str]
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Process login with dict data.
|
"""Process login with dict data.
|
||||||
|
|
||||||
@@ -63,11 +72,13 @@ class APIAuth(CoreSysAttributes):
|
|||||||
|
|
||||||
# BasicAuth
|
# BasicAuth
|
||||||
if AUTHORIZATION in request.headers:
|
if AUTHORIZATION in request.headers:
|
||||||
return await self._process_basic(request, addon)
|
if not await self._process_basic(request, addon):
|
||||||
|
raise HTTPUnauthorized(headers=REALM_HEADER)
|
||||||
|
return True
|
||||||
|
|
||||||
# Json
|
# Json
|
||||||
if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_JSON:
|
if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_JSON:
|
||||||
data = await request.json()
|
data = await request.json(loads=json_loads)
|
||||||
return await self._process_dict(request, addon, data)
|
return await self._process_dict(request, addon, data)
|
||||||
|
|
||||||
# URL encoded
|
# URL encoded
|
||||||
@@ -75,14 +86,12 @@ class APIAuth(CoreSysAttributes):
|
|||||||
data = await request.post()
|
data = await request.post()
|
||||||
return await self._process_dict(request, addon, data)
|
return await self._process_dict(request, addon, data)
|
||||||
|
|
||||||
raise HTTPUnauthorized(
|
raise HTTPUnauthorized(headers=REALM_HEADER)
|
||||||
headers={WWW_AUTHENTICATE: 'Basic realm="Home Assistant Authentication"'}
|
|
||||||
)
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def reset(self, request: web.Request) -> None:
|
async def reset(self, request: web.Request) -> None:
|
||||||
"""Process reset password request."""
|
"""Process reset password request."""
|
||||||
body: Dict[str, str] = await api_validate(SCHEMA_PASSWORD_RESET, request)
|
body: dict[str, str] = await api_validate(SCHEMA_PASSWORD_RESET, request)
|
||||||
await asyncio.shield(
|
await asyncio.shield(
|
||||||
self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD])
|
self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD])
|
||||||
)
|
)
|
||||||
@@ -91,3 +100,21 @@ class APIAuth(CoreSysAttributes):
|
|||||||
async def cache(self, request: web.Request) -> None:
|
async def cache(self, request: web.Request) -> None:
|
||||||
"""Process cache reset request."""
|
"""Process cache reset request."""
|
||||||
self.sys_auth.reset_data()
|
self.sys_auth.reset_data()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def list_users(self, request: web.Request) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""List users on the Home Assistant instance."""
|
||||||
|
return {
|
||||||
|
ATTR_USERS: [
|
||||||
|
{
|
||||||
|
ATTR_USERNAME: user[ATTR_USERNAME],
|
||||||
|
ATTR_NAME: user[ATTR_NAME],
|
||||||
|
ATTR_IS_OWNER: user[ATTR_IS_OWNER],
|
||||||
|
ATTR_IS_ACTIVE: user[ATTR_IS_ACTIVE],
|
||||||
|
ATTR_LOCAL_ONLY: user[ATTR_LOCAL_ONLY],
|
||||||
|
ATTR_GROUP_IDS: user[ATTR_GROUP_IDS],
|
||||||
|
}
|
||||||
|
for user in await self.sys_auth.list_users()
|
||||||
|
if user[ATTR_USERNAME]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
379
supervisor/api/backups.py
Normal file
379
supervisor/api/backups.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""Backups RESTful API."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
import errno
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.hdrs import CONTENT_DISPOSITION
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from ..backups.backup import Backup
|
||||||
|
from ..backups.validate import ALL_FOLDERS, FOLDER_HOMEASSISTANT, days_until_stale
|
||||||
|
from ..const import (
|
||||||
|
ATTR_ADDONS,
|
||||||
|
ATTR_BACKUPS,
|
||||||
|
ATTR_COMPRESSED,
|
||||||
|
ATTR_CONTENT,
|
||||||
|
ATTR_DATE,
|
||||||
|
ATTR_DAYS_UNTIL_STALE,
|
||||||
|
ATTR_FOLDERS,
|
||||||
|
ATTR_HOMEASSISTANT,
|
||||||
|
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||||
|
ATTR_LOCATON,
|
||||||
|
ATTR_NAME,
|
||||||
|
ATTR_PASSWORD,
|
||||||
|
ATTR_PROTECTED,
|
||||||
|
ATTR_REPOSITORIES,
|
||||||
|
ATTR_SIZE,
|
||||||
|
ATTR_SLUG,
|
||||||
|
ATTR_SUPERVISOR_VERSION,
|
||||||
|
ATTR_TIMEOUT,
|
||||||
|
ATTR_TYPE,
|
||||||
|
ATTR_VERSION,
|
||||||
|
BusEvent,
|
||||||
|
CoreState,
|
||||||
|
)
|
||||||
|
from ..coresys import CoreSysAttributes
|
||||||
|
from ..exceptions import APIError
|
||||||
|
from ..jobs import JobSchedulerOptions
|
||||||
|
from ..mounts.const import MountUsage
|
||||||
|
from ..resolution.const import UnhealthyReason
|
||||||
|
from .const import ATTR_BACKGROUND, ATTR_JOB_ID, CONTENT_TYPE_TAR
|
||||||
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RE_SLUGIFY_NAME = re.compile(r"[^A-Za-z0-9]+")
|
||||||
|
|
||||||
|
# Backwards compatible
|
||||||
|
# Remove: 2022.08
|
||||||
|
_ALL_FOLDERS = ALL_FOLDERS + [FOLDER_HOMEASSISTANT]
|
||||||
|
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
|
SCHEMA_RESTORE_FULL = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
|
||||||
|
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()),
|
||||||
|
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_BACKUP_FULL = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_NAME): str,
|
||||||
|
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
|
||||||
|
vol.Optional(ATTR_COMPRESSED): vol.Maybe(vol.Boolean()),
|
||||||
|
vol.Optional(ATTR_LOCATON): vol.Maybe(str),
|
||||||
|
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_BACKGROUND, default=False): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_ADDONS): vol.All([str], vol.Unique()),
|
||||||
|
vol.Optional(ATTR_FOLDERS): vol.All([vol.In(_ALL_FOLDERS)], vol.Unique()),
|
||||||
|
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_OPTIONS = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_DAYS_UNTIL_STALE): days_until_stale,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_FREEZE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_TIMEOUT): vol.All(int, vol.Range(min=1)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class APIBackups(CoreSysAttributes):
|
||||||
|
"""Handle RESTful API for backups functions."""
|
||||||
|
|
||||||
|
def _extract_slug(self, request):
|
||||||
|
"""Return backup, throw an exception if it doesn't exist."""
|
||||||
|
backup = self.sys_backups.get(request.match_info.get("slug"))
|
||||||
|
if not backup:
|
||||||
|
raise APIError("Backup does not exist")
|
||||||
|
return backup
|
||||||
|
|
||||||
|
def _list_backups(self):
|
||||||
|
"""Return list of backups."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
ATTR_SLUG: backup.slug,
|
||||||
|
ATTR_NAME: backup.name,
|
||||||
|
ATTR_DATE: backup.date,
|
||||||
|
ATTR_TYPE: backup.sys_type,
|
||||||
|
ATTR_SIZE: backup.size,
|
||||||
|
ATTR_LOCATON: backup.location,
|
||||||
|
ATTR_PROTECTED: backup.protected,
|
||||||
|
ATTR_COMPRESSED: backup.compressed,
|
||||||
|
ATTR_CONTENT: {
|
||||||
|
ATTR_HOMEASSISTANT: backup.homeassistant_version is not None,
|
||||||
|
ATTR_ADDONS: backup.addon_list,
|
||||||
|
ATTR_FOLDERS: backup.folders,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for backup in self.sys_backups.list_backups
|
||||||
|
]
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def list(self, request):
|
||||||
|
"""Return backup list."""
|
||||||
|
data_backups = self._list_backups()
|
||||||
|
|
||||||
|
if request.path == "/snapshots":
|
||||||
|
# Kept for backwards compability
|
||||||
|
return {"snapshots": data_backups}
|
||||||
|
|
||||||
|
return {ATTR_BACKUPS: data_backups}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def info(self, request):
|
||||||
|
"""Return backup list and manager info."""
|
||||||
|
return {
|
||||||
|
ATTR_BACKUPS: self._list_backups(),
|
||||||
|
ATTR_DAYS_UNTIL_STALE: self.sys_backups.days_until_stale,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def options(self, request):
|
||||||
|
"""Set backup manager options."""
|
||||||
|
body = await api_validate(SCHEMA_OPTIONS, request)
|
||||||
|
|
||||||
|
if ATTR_DAYS_UNTIL_STALE in body:
|
||||||
|
self.sys_backups.days_until_stale = body[ATTR_DAYS_UNTIL_STALE]
|
||||||
|
|
||||||
|
self.sys_backups.save_data()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def reload(self, _):
|
||||||
|
"""Reload backup list."""
|
||||||
|
await asyncio.shield(self.sys_backups.reload())
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def backup_info(self, request):
|
||||||
|
"""Return backup info."""
|
||||||
|
backup = self._extract_slug(request)
|
||||||
|
|
||||||
|
data_addons = []
|
||||||
|
for addon_data in backup.addons:
|
||||||
|
data_addons.append(
|
||||||
|
{
|
||||||
|
ATTR_SLUG: addon_data[ATTR_SLUG],
|
||||||
|
ATTR_NAME: addon_data[ATTR_NAME],
|
||||||
|
ATTR_VERSION: addon_data[ATTR_VERSION],
|
||||||
|
ATTR_SIZE: addon_data[ATTR_SIZE],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ATTR_SLUG: backup.slug,
|
||||||
|
ATTR_TYPE: backup.sys_type,
|
||||||
|
ATTR_NAME: backup.name,
|
||||||
|
ATTR_DATE: backup.date,
|
||||||
|
ATTR_SIZE: backup.size,
|
||||||
|
ATTR_COMPRESSED: backup.compressed,
|
||||||
|
ATTR_PROTECTED: backup.protected,
|
||||||
|
ATTR_SUPERVISOR_VERSION: backup.supervisor_version,
|
||||||
|
ATTR_HOMEASSISTANT: backup.homeassistant_version,
|
||||||
|
ATTR_LOCATON: backup.location,
|
||||||
|
ATTR_ADDONS: data_addons,
|
||||||
|
ATTR_REPOSITORIES: backup.repositories,
|
||||||
|
ATTR_FOLDERS: backup.folders,
|
||||||
|
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE: backup.homeassistant_exclude_database,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _location_to_mount(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Change location field to mount if necessary."""
|
||||||
|
if not body.get(ATTR_LOCATON):
|
||||||
|
return body
|
||||||
|
|
||||||
|
body[ATTR_LOCATON] = self.sys_mounts.get(body[ATTR_LOCATON])
|
||||||
|
if body[ATTR_LOCATON].usage != MountUsage.BACKUP:
|
||||||
|
raise APIError(
|
||||||
|
f"Mount {body[ATTR_LOCATON].name} is not used for backups, cannot backup to there"
|
||||||
|
)
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def _background_backup_task(
|
||||||
|
self, backup_method: Callable, *args, **kwargs
|
||||||
|
) -> tuple[asyncio.Task, str]:
|
||||||
|
"""Start backup task in background and return task and job ID."""
|
||||||
|
event = asyncio.Event()
|
||||||
|
job, backup_task = self.sys_jobs.schedule_job(
|
||||||
|
backup_method, JobSchedulerOptions(), *args, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
async def release_on_freeze(new_state: CoreState):
|
||||||
|
if new_state == CoreState.FREEZE:
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
# Wait for system to get into freeze state before returning
|
||||||
|
# If the backup fails validation it will raise before getting there
|
||||||
|
listener = self.sys_bus.register_event(
|
||||||
|
BusEvent.SUPERVISOR_STATE_CHANGE, release_on_freeze
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait(
|
||||||
|
(
|
||||||
|
backup_task,
|
||||||
|
self.sys_create_task(event.wait()),
|
||||||
|
),
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
return (backup_task, job.uuid)
|
||||||
|
finally:
|
||||||
|
self.sys_bus.remove_listener(listener)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def backup_full(self, request):
|
||||||
|
"""Create full backup."""
|
||||||
|
body = await api_validate(SCHEMA_BACKUP_FULL, request)
|
||||||
|
background = body.pop(ATTR_BACKGROUND)
|
||||||
|
backup_task, job_id = await self._background_backup_task(
|
||||||
|
self.sys_backups.do_backup_full, **self._location_to_mount(body)
|
||||||
|
)
|
||||||
|
|
||||||
|
if background and not backup_task.done():
|
||||||
|
return {ATTR_JOB_ID: job_id}
|
||||||
|
|
||||||
|
backup: Backup = await backup_task
|
||||||
|
if backup:
|
||||||
|
return {ATTR_JOB_ID: job_id, ATTR_SLUG: backup.slug}
|
||||||
|
raise APIError(
|
||||||
|
f"An error occurred while making backup, check job '{job_id}' or supervisor logs for details",
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def backup_partial(self, request):
|
||||||
|
"""Create a partial backup."""
|
||||||
|
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
|
||||||
|
background = body.pop(ATTR_BACKGROUND)
|
||||||
|
backup_task, job_id = await self._background_backup_task(
|
||||||
|
self.sys_backups.do_backup_partial, **self._location_to_mount(body)
|
||||||
|
)
|
||||||
|
|
||||||
|
if background and not backup_task.done():
|
||||||
|
return {ATTR_JOB_ID: job_id}
|
||||||
|
|
||||||
|
backup: Backup = await backup_task
|
||||||
|
if backup:
|
||||||
|
return {ATTR_JOB_ID: job_id, ATTR_SLUG: backup.slug}
|
||||||
|
raise APIError(
|
||||||
|
f"An error occurred while making backup, check job '{job_id}' or supervisor logs for details",
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def restore_full(self, request):
|
||||||
|
"""Full restore of a backup."""
|
||||||
|
backup = self._extract_slug(request)
|
||||||
|
body = await api_validate(SCHEMA_RESTORE_FULL, request)
|
||||||
|
background = body.pop(ATTR_BACKGROUND)
|
||||||
|
restore_task, job_id = await self._background_backup_task(
|
||||||
|
self.sys_backups.do_restore_full, backup, **body
|
||||||
|
)
|
||||||
|
|
||||||
|
if background and not restore_task.done() or await restore_task:
|
||||||
|
return {ATTR_JOB_ID: job_id}
|
||||||
|
raise APIError(
|
||||||
|
f"An error occurred during restore of {backup.slug}, check job '{job_id}' or supervisor logs for details",
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def restore_partial(self, request):
|
||||||
|
"""Partial restore a backup."""
|
||||||
|
backup = self._extract_slug(request)
|
||||||
|
body = await api_validate(SCHEMA_RESTORE_PARTIAL, request)
|
||||||
|
background = body.pop(ATTR_BACKGROUND)
|
||||||
|
restore_task, job_id = await self._background_backup_task(
|
||||||
|
self.sys_backups.do_restore_partial, backup, **body
|
||||||
|
)
|
||||||
|
|
||||||
|
if background and not restore_task.done() or await restore_task:
|
||||||
|
return {ATTR_JOB_ID: job_id}
|
||||||
|
raise APIError(
|
||||||
|
f"An error occurred during restore of {backup.slug}, check job '{job_id}' or supervisor logs for details",
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def freeze(self, request):
|
||||||
|
"""Initiate manual freeze for external backup."""
|
||||||
|
body = await api_validate(SCHEMA_FREEZE, request)
|
||||||
|
await asyncio.shield(self.sys_backups.freeze_all(**body))
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def thaw(self, request):
|
||||||
|
"""Begin thaw after manual freeze."""
|
||||||
|
await self.sys_backups.thaw_all()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def remove(self, request):
|
||||||
|
"""Remove a backup."""
|
||||||
|
backup = self._extract_slug(request)
|
||||||
|
return self.sys_backups.remove(backup)
|
||||||
|
|
||||||
|
async def download(self, request):
|
||||||
|
"""Download a backup file."""
|
||||||
|
backup = self._extract_slug(request)
|
||||||
|
|
||||||
|
_LOGGER.info("Downloading backup %s", backup.slug)
|
||||||
|
response = web.FileResponse(backup.tarfile)
|
||||||
|
response.content_type = CONTENT_TYPE_TAR
|
||||||
|
response.headers[CONTENT_DISPOSITION] = (
|
||||||
|
f"attachment; filename={RE_SLUGIFY_NAME.sub('_', backup.name)}.tar"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def upload(self, request):
|
||||||
|
"""Upload a backup file."""
|
||||||
|
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp_dir:
|
||||||
|
tar_file = Path(temp_dir, "backup.tar")
|
||||||
|
reader = await request.multipart()
|
||||||
|
contents = await reader.next()
|
||||||
|
try:
|
||||||
|
with tar_file.open("wb") as backup:
|
||||||
|
while True:
|
||||||
|
chunk = await contents.read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
backup.write(chunk)
|
||||||
|
|
||||||
|
except OSError as err:
|
||||||
|
if err.errno == errno.EBADMSG:
|
||||||
|
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
|
||||||
|
_LOGGER.error("Can't write new backup file: %s", err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
backup = await asyncio.shield(self.sys_backups.import_backup(tar_file))
|
||||||
|
|
||||||
|
if backup:
|
||||||
|
return {ATTR_SLUG: backup.slug}
|
||||||
|
return False
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Init file for Supervisor HA cli RESTful API."""
|
"""Init file for Supervisor HA cli RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -32,7 +33,7 @@ class APICli(CoreSysAttributes):
|
|||||||
"""Handle RESTful API for HA Cli functions."""
|
"""Handle RESTful API for HA Cli functions."""
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return HA cli information."""
|
"""Return HA cli information."""
|
||||||
return {
|
return {
|
||||||
ATTR_VERSION: self.sys_plugins.cli.version,
|
ATTR_VERSION: self.sys_plugins.cli.version,
|
||||||
@@ -41,7 +42,7 @@ class APICli(CoreSysAttributes):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return resource information."""
|
"""Return resource information."""
|
||||||
stats = await self.sys_plugins.cli.stats()
|
stats = await self.sys_plugins.cli.stats()
|
||||||
|
|
||||||
|
|||||||
79
supervisor/api/const.py
Normal file
79
supervisor/api/const.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Const for API."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
CONTENT_TYPE_BINARY = "application/octet-stream"
|
||||||
|
CONTENT_TYPE_JSON = "application/json"
|
||||||
|
CONTENT_TYPE_PNG = "image/png"
|
||||||
|
CONTENT_TYPE_TAR = "application/tar"
|
||||||
|
CONTENT_TYPE_TEXT = "text/plain"
|
||||||
|
CONTENT_TYPE_URL = "application/x-www-form-urlencoded"
|
||||||
|
CONTENT_TYPE_X_LOG = "text/x-log"
|
||||||
|
|
||||||
|
COOKIE_INGRESS = "ingress_session"
|
||||||
|
|
||||||
|
ATTR_AGENT_VERSION = "agent_version"
|
||||||
|
ATTR_APPARMOR_VERSION = "apparmor_version"
|
||||||
|
ATTR_ATTRIBUTES = "attributes"
|
||||||
|
ATTR_AVAILABLE_UPDATES = "available_updates"
|
||||||
|
ATTR_BACKGROUND = "background"
|
||||||
|
ATTR_BOOT_CONFIG = "boot_config"
|
||||||
|
ATTR_BOOT_SLOT = "boot_slot"
|
||||||
|
ATTR_BOOT_SLOTS = "boot_slots"
|
||||||
|
ATTR_BOOT_TIMESTAMP = "boot_timestamp"
|
||||||
|
ATTR_BOOTS = "boots"
|
||||||
|
ATTR_BROADCAST_LLMNR = "broadcast_llmnr"
|
||||||
|
ATTR_BROADCAST_MDNS = "broadcast_mdns"
|
||||||
|
ATTR_BY_ID = "by_id"
|
||||||
|
ATTR_CHILDREN = "children"
|
||||||
|
ATTR_CONNECTION_BUS = "connection_bus"
|
||||||
|
ATTR_DATA_DISK = "data_disk"
|
||||||
|
ATTR_DEVICE = "device"
|
||||||
|
ATTR_DEV_PATH = "dev_path"
|
||||||
|
ATTR_DISKS = "disks"
|
||||||
|
ATTR_DRIVES = "drives"
|
||||||
|
ATTR_DT_SYNCHRONIZED = "dt_synchronized"
|
||||||
|
ATTR_DT_UTC = "dt_utc"
|
||||||
|
ATTR_EJECTABLE = "ejectable"
|
||||||
|
ATTR_FALLBACK = "fallback"
|
||||||
|
ATTR_FILESYSTEMS = "filesystems"
|
||||||
|
ATTR_FORCE = "force"
|
||||||
|
ATTR_GROUP_IDS = "group_ids"
|
||||||
|
ATTR_IDENTIFIERS = "identifiers"
|
||||||
|
ATTR_IS_ACTIVE = "is_active"
|
||||||
|
ATTR_IS_OWNER = "is_owner"
|
||||||
|
ATTR_JOB_ID = "job_id"
|
||||||
|
ATTR_JOBS = "jobs"
|
||||||
|
ATTR_LLMNR = "llmnr"
|
||||||
|
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
|
||||||
|
ATTR_LOCAL_ONLY = "local_only"
|
||||||
|
ATTR_MDNS = "mdns"
|
||||||
|
ATTR_MODEL = "model"
|
||||||
|
ATTR_MOUNTS = "mounts"
|
||||||
|
ATTR_MOUNT_POINTS = "mount_points"
|
||||||
|
ATTR_PANEL_PATH = "panel_path"
|
||||||
|
ATTR_REMOVABLE = "removable"
|
||||||
|
ATTR_REMOVE_CONFIG = "remove_config"
|
||||||
|
ATTR_REVISION = "revision"
|
||||||
|
ATTR_SAFE_MODE = "safe_mode"
|
||||||
|
ATTR_SEAT = "seat"
|
||||||
|
ATTR_SIGNED = "signed"
|
||||||
|
ATTR_STARTUP_TIME = "startup_time"
|
||||||
|
ATTR_STATUS = "status"
|
||||||
|
ATTR_SUBSYSTEM = "subsystem"
|
||||||
|
ATTR_SYSFS = "sysfs"
|
||||||
|
ATTR_SYSTEM_HEALTH_LED = "system_health_led"
|
||||||
|
ATTR_TIME_DETECTED = "time_detected"
|
||||||
|
ATTR_UPDATE_TYPE = "update_type"
|
||||||
|
ATTR_USAGE = "usage"
|
||||||
|
ATTR_USE_NTP = "use_ntp"
|
||||||
|
ATTR_USERS = "users"
|
||||||
|
ATTR_VENDOR = "vendor"
|
||||||
|
ATTR_VIRTUALIZATION = "virtualization"
|
||||||
|
|
||||||
|
|
||||||
|
class BootSlot(StrEnum):
|
||||||
|
"""Boot slots used by HAOS."""
|
||||||
|
|
||||||
|
A = "A"
|
||||||
|
B = "B"
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
"""Init file for Supervisor network RESTful API."""
|
"""Init file for Supervisor network RESTful API."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from ..addons.addon import Addon
|
||||||
from ..const import (
|
from ..const import (
|
||||||
ATTR_ADDON,
|
ATTR_ADDON,
|
||||||
ATTR_CONFIG,
|
ATTR_CONFIG,
|
||||||
@@ -9,16 +13,18 @@ from ..const import (
|
|||||||
ATTR_SERVICES,
|
ATTR_SERVICES,
|
||||||
ATTR_UUID,
|
ATTR_UUID,
|
||||||
REQUEST_FROM,
|
REQUEST_FROM,
|
||||||
|
AddonState,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..discovery.validate import valid_discovery_service
|
|
||||||
from ..exceptions import APIError, APIForbidden
|
from ..exceptions import APIError, APIForbidden
|
||||||
from .utils import api_process, api_validate
|
from .utils import api_process, api_validate, require_home_assistant
|
||||||
|
|
||||||
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCHEMA_DISCOVERY = vol.Schema(
|
SCHEMA_DISCOVERY = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_SERVICE): valid_discovery_service,
|
vol.Required(ATTR_SERVICE): str,
|
||||||
vol.Optional(ATTR_CONFIG): vol.Maybe(dict),
|
vol.Required(ATTR_CONFIG): dict,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,27 +39,22 @@ class APIDiscovery(CoreSysAttributes):
|
|||||||
raise APIError("Discovery message not found")
|
raise APIError("Discovery message not found")
|
||||||
return message
|
return message
|
||||||
|
|
||||||
def _check_permission_ha(self, request):
|
|
||||||
"""Check permission for API call / Home Assistant."""
|
|
||||||
if request[REQUEST_FROM] != self.sys_homeassistant:
|
|
||||||
raise APIForbidden("Only HomeAssistant can use this API!")
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
|
@require_home_assistant
|
||||||
async def list(self, request):
|
async def list(self, request):
|
||||||
"""Show register services."""
|
"""Show registered and available services."""
|
||||||
self._check_permission_ha(request)
|
|
||||||
|
|
||||||
# Get available discovery
|
# Get available discovery
|
||||||
discovery = []
|
discovery = [
|
||||||
for message in self.sys_discovery.list_messages:
|
|
||||||
discovery.append(
|
|
||||||
{
|
{
|
||||||
ATTR_ADDON: message.addon,
|
ATTR_ADDON: message.addon,
|
||||||
ATTR_SERVICE: message.service,
|
ATTR_SERVICE: message.service,
|
||||||
ATTR_UUID: message.uuid,
|
ATTR_UUID: message.uuid,
|
||||||
ATTR_CONFIG: message.config,
|
ATTR_CONFIG: message.config,
|
||||||
}
|
}
|
||||||
)
|
for message in self.sys_discovery.list_messages
|
||||||
|
if (addon := self.sys_addons.get(message.addon, local_only=True))
|
||||||
|
and addon.state == AddonState.STARTED
|
||||||
|
]
|
||||||
|
|
||||||
# Get available services/add-ons
|
# Get available services/add-ons
|
||||||
services = {}
|
services = {}
|
||||||
@@ -67,11 +68,19 @@ class APIDiscovery(CoreSysAttributes):
|
|||||||
async def set_discovery(self, request):
|
async def set_discovery(self, request):
|
||||||
"""Write data into a discovery pipeline."""
|
"""Write data into a discovery pipeline."""
|
||||||
body = await api_validate(SCHEMA_DISCOVERY, request)
|
body = await api_validate(SCHEMA_DISCOVERY, request)
|
||||||
addon = request[REQUEST_FROM]
|
addon: Addon = request[REQUEST_FROM]
|
||||||
|
service = body[ATTR_SERVICE]
|
||||||
|
|
||||||
# Access?
|
# Access?
|
||||||
if body[ATTR_SERVICE] not in addon.discovery:
|
if body[ATTR_SERVICE] not in addon.discovery:
|
||||||
raise APIForbidden("Can't use discovery!")
|
_LOGGER.error(
|
||||||
|
"Add-on %s attempted to send discovery for service %s which is not listed in its config. Please report this to the maintainer of the add-on",
|
||||||
|
addon.name,
|
||||||
|
service,
|
||||||
|
)
|
||||||
|
raise APIForbidden(
|
||||||
|
"Add-ons must list services they provide via discovery in their config!"
|
||||||
|
)
|
||||||
|
|
||||||
# Process discovery message
|
# Process discovery message
|
||||||
message = self.sys_discovery.send(addon, **body)
|
message = self.sys_discovery.send(addon, **body)
|
||||||
@@ -79,13 +88,11 @@ class APIDiscovery(CoreSysAttributes):
|
|||||||
return {ATTR_UUID: message.uuid}
|
return {ATTR_UUID: message.uuid}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
|
@require_home_assistant
|
||||||
async def get_discovery(self, request):
|
async def get_discovery(self, request):
|
||||||
"""Read data into a discovery message."""
|
"""Read data into a discovery message."""
|
||||||
message = self._extract_message(request)
|
message = self._extract_message(request)
|
||||||
|
|
||||||
# HomeAssistant?
|
|
||||||
self._check_permission_ha(request)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ATTR_ADDON: message.addon,
|
ATTR_ADDON: message.addon,
|
||||||
ATTR_SERVICE: message.service,
|
ATTR_SERVICE: message.service,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Init file for Supervisor DNS RESTful API."""
|
"""Init file for Supervisor DNS RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -21,17 +23,22 @@ from ..const import (
|
|||||||
ATTR_UPDATE_AVAILABLE,
|
ATTR_UPDATE_AVAILABLE,
|
||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
ATTR_VERSION_LATEST,
|
ATTR_VERSION_LATEST,
|
||||||
CONTENT_TYPE_BINARY,
|
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..exceptions import APIError
|
from ..exceptions import APIError
|
||||||
from ..validate import dns_server_list, version_tag
|
from ..validate import dns_server_list, version_tag
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .const import ATTR_FALLBACK, ATTR_LLMNR, ATTR_MDNS
|
||||||
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# pylint: disable=no-value-for-parameter
|
# pylint: disable=no-value-for-parameter
|
||||||
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_SERVERS): dns_server_list})
|
SCHEMA_OPTIONS = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_SERVERS): dns_server_list,
|
||||||
|
vol.Optional(ATTR_FALLBACK): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
||||||
|
|
||||||
@@ -40,7 +47,7 @@ class APICoreDNS(CoreSysAttributes):
|
|||||||
"""Handle RESTful API for DNS functions."""
|
"""Handle RESTful API for DNS functions."""
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return DNS information."""
|
"""Return DNS information."""
|
||||||
return {
|
return {
|
||||||
ATTR_VERSION: self.sys_plugins.dns.version,
|
ATTR_VERSION: self.sys_plugins.dns.version,
|
||||||
@@ -49,21 +56,32 @@ class APICoreDNS(CoreSysAttributes):
|
|||||||
ATTR_HOST: str(self.sys_docker.network.dns),
|
ATTR_HOST: str(self.sys_docker.network.dns),
|
||||||
ATTR_SERVERS: self.sys_plugins.dns.servers,
|
ATTR_SERVERS: self.sys_plugins.dns.servers,
|
||||||
ATTR_LOCALS: self.sys_plugins.dns.locals,
|
ATTR_LOCALS: self.sys_plugins.dns.locals,
|
||||||
|
ATTR_MDNS: self.sys_plugins.dns.mdns,
|
||||||
|
ATTR_LLMNR: self.sys_plugins.dns.llmnr,
|
||||||
|
ATTR_FALLBACK: self.sys_plugins.dns.fallback,
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def options(self, request: web.Request) -> None:
|
async def options(self, request: web.Request) -> None:
|
||||||
"""Set DNS options."""
|
"""Set DNS options."""
|
||||||
body = await api_validate(SCHEMA_OPTIONS, request)
|
body = await api_validate(SCHEMA_OPTIONS, request)
|
||||||
|
restart_required = False
|
||||||
|
|
||||||
if ATTR_SERVERS in body:
|
if ATTR_SERVERS in body:
|
||||||
self.sys_plugins.dns.servers = body[ATTR_SERVERS]
|
self.sys_plugins.dns.servers = body[ATTR_SERVERS]
|
||||||
|
restart_required = True
|
||||||
|
|
||||||
|
if ATTR_FALLBACK in body:
|
||||||
|
self.sys_plugins.dns.fallback = body[ATTR_FALLBACK]
|
||||||
|
restart_required = True
|
||||||
|
|
||||||
|
if restart_required:
|
||||||
self.sys_create_task(self.sys_plugins.dns.restart())
|
self.sys_create_task(self.sys_plugins.dns.restart())
|
||||||
|
|
||||||
self.sys_plugins.dns.save_data()
|
self.sys_plugins.dns.save_data()
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return resource information."""
|
"""Return resource information."""
|
||||||
stats = await self.sys_plugins.dns.stats()
|
stats = await self.sys_plugins.dns.stats()
|
||||||
|
|
||||||
@@ -88,11 +106,6 @@ class APICoreDNS(CoreSysAttributes):
|
|||||||
raise APIError(f"Version {version} is already in use")
|
raise APIError(f"Version {version} is already in use")
|
||||||
await asyncio.shield(self.sys_plugins.dns.update(version))
|
await asyncio.shield(self.sys_plugins.dns.update(version))
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
|
||||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
|
||||||
"""Return DNS Docker logs."""
|
|
||||||
return self.sys_plugins.dns.logs()
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def restart(self, request: web.Request) -> Awaitable[None]:
|
def restart(self, request: web.Request) -> Awaitable[None]:
|
||||||
"""Restart CoreDNS plugin."""
|
"""Restart CoreDNS plugin."""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -21,7 +22,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
SCHEMA_DOCKER_REGISTRY = vol.Schema(
|
SCHEMA_DOCKER_REGISTRY = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Coerce(str): {
|
str: {
|
||||||
vol.Required(ATTR_USERNAME): str,
|
vol.Required(ATTR_USERNAME): str,
|
||||||
vol.Required(ATTR_PASSWORD): str,
|
vol.Required(ATTR_PASSWORD): str,
|
||||||
}
|
}
|
||||||
@@ -33,7 +34,7 @@ class APIDocker(CoreSysAttributes):
|
|||||||
"""Handle RESTful API for Docker configuration."""
|
"""Handle RESTful API for Docker configuration."""
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def registries(self, request) -> Dict[str, Any]:
|
async def registries(self, request) -> dict[str, Any]:
|
||||||
"""Return the list of registries."""
|
"""Return the list of registries."""
|
||||||
data_registries = {}
|
data_registries = {}
|
||||||
for hostname, registry in self.sys_docker.config.registries.items():
|
for hostname, registry in self.sys_docker.config.registries.items():
|
||||||
|
|||||||
@@ -1,54 +1,118 @@
|
|||||||
"""Init file for Supervisor hardware RESTful API."""
|
"""Init file for Supervisor hardware RESTful API."""
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Dict, List
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
ATTR_AUDIO,
|
ATTR_AUDIO,
|
||||||
ATTR_DISK,
|
ATTR_DEVICES,
|
||||||
ATTR_GPIO,
|
ATTR_ID,
|
||||||
ATTR_INPUT,
|
ATTR_INPUT,
|
||||||
|
ATTR_NAME,
|
||||||
ATTR_OUTPUT,
|
ATTR_OUTPUT,
|
||||||
ATTR_SERIAL,
|
ATTR_SERIAL,
|
||||||
ATTR_USB,
|
ATTR_SIZE,
|
||||||
|
ATTR_SYSTEM,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
|
from ..dbus.udisks2 import UDisks2Manager
|
||||||
|
from ..dbus.udisks2.block import UDisks2Block
|
||||||
|
from ..dbus.udisks2.drive import UDisks2Drive
|
||||||
|
from ..hardware.data import Device
|
||||||
|
from .const import (
|
||||||
|
ATTR_ATTRIBUTES,
|
||||||
|
ATTR_BY_ID,
|
||||||
|
ATTR_CHILDREN,
|
||||||
|
ATTR_CONNECTION_BUS,
|
||||||
|
ATTR_DEV_PATH,
|
||||||
|
ATTR_DEVICE,
|
||||||
|
ATTR_DRIVES,
|
||||||
|
ATTR_EJECTABLE,
|
||||||
|
ATTR_FILESYSTEMS,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_MOUNT_POINTS,
|
||||||
|
ATTR_REMOVABLE,
|
||||||
|
ATTR_REVISION,
|
||||||
|
ATTR_SEAT,
|
||||||
|
ATTR_SUBSYSTEM,
|
||||||
|
ATTR_SYSFS,
|
||||||
|
ATTR_TIME_DETECTED,
|
||||||
|
ATTR_VENDOR,
|
||||||
|
)
|
||||||
from .utils import api_process
|
from .utils import api_process
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def device_struct(device: Device) -> dict[str, Any]:
|
||||||
|
"""Return a dict with information of a interface to be used in the API."""
|
||||||
|
return {
|
||||||
|
ATTR_NAME: device.name,
|
||||||
|
ATTR_SYSFS: device.sysfs,
|
||||||
|
ATTR_DEV_PATH: device.path,
|
||||||
|
ATTR_SUBSYSTEM: device.subsystem,
|
||||||
|
ATTR_BY_ID: device.by_id,
|
||||||
|
ATTR_ATTRIBUTES: device.attributes,
|
||||||
|
ATTR_CHILDREN: device.children,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def filesystem_struct(fs_block: UDisks2Block) -> dict[str, Any]:
|
||||||
|
"""Return a dict with information of a filesystem block device to be used in the API."""
|
||||||
|
return {
|
||||||
|
ATTR_DEVICE: str(fs_block.device),
|
||||||
|
ATTR_ID: fs_block.id,
|
||||||
|
ATTR_SIZE: fs_block.size,
|
||||||
|
ATTR_NAME: fs_block.id_label,
|
||||||
|
ATTR_SYSTEM: fs_block.hint_system,
|
||||||
|
ATTR_MOUNT_POINTS: [
|
||||||
|
str(mount_point) for mount_point in fs_block.filesystem.mount_points
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def drive_struct(udisks2: UDisks2Manager, drive: UDisks2Drive) -> dict[str, Any]:
|
||||||
|
"""Return a dict with information of a disk to be used in the API."""
|
||||||
|
return {
|
||||||
|
ATTR_VENDOR: drive.vendor,
|
||||||
|
ATTR_MODEL: drive.model,
|
||||||
|
ATTR_REVISION: drive.revision,
|
||||||
|
ATTR_SERIAL: drive.serial,
|
||||||
|
ATTR_ID: drive.id,
|
||||||
|
ATTR_SIZE: drive.size,
|
||||||
|
ATTR_TIME_DETECTED: drive.time_detected.isoformat(),
|
||||||
|
ATTR_CONNECTION_BUS: drive.connection_bus,
|
||||||
|
ATTR_SEAT: drive.seat,
|
||||||
|
ATTR_REMOVABLE: drive.removable,
|
||||||
|
ATTR_EJECTABLE: drive.ejectable,
|
||||||
|
ATTR_FILESYSTEMS: [
|
||||||
|
filesystem_struct(block)
|
||||||
|
for block in udisks2.block_devices
|
||||||
|
if block.filesystem and block.drive == drive.object_path
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class APIHardware(CoreSysAttributes):
|
class APIHardware(CoreSysAttributes):
|
||||||
"""Handle RESTful API for hardware functions."""
|
"""Handle RESTful API for hardware functions."""
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Show hardware info."""
|
"""Show hardware info."""
|
||||||
serial: List[str] = []
|
|
||||||
|
|
||||||
# Create Serial list with device links
|
|
||||||
for device in self.sys_hardware.serial_devices:
|
|
||||||
serial.append(device.path.as_posix())
|
|
||||||
for link in device.links:
|
|
||||||
serial.append(link.as_posix())
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ATTR_SERIAL: serial,
|
ATTR_DEVICES: [
|
||||||
ATTR_INPUT: list(self.sys_hardware.input_devices),
|
device_struct(device) for device in self.sys_hardware.devices
|
||||||
ATTR_DISK: [
|
|
||||||
device.path.as_posix() for device in self.sys_hardware.disk_devices
|
|
||||||
],
|
],
|
||||||
ATTR_GPIO: list(self.sys_hardware.gpio_devices),
|
ATTR_DRIVES: [
|
||||||
ATTR_USB: [
|
drive_struct(self.sys_dbus.udisks2, drive)
|
||||||
device.path.as_posix() for device in self.sys_hardware.usb_devices
|
for drive in self.sys_dbus.udisks2.drives
|
||||||
],
|
],
|
||||||
ATTR_AUDIO: self.sys_hardware.audio_devices,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def audio(self, request: web.Request) -> Dict[str, Any]:
|
async def audio(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Show pulse audio profiles."""
|
"""Show pulse audio profiles."""
|
||||||
return {
|
return {
|
||||||
ATTR_AUDIO: {
|
ATTR_AUDIO: {
|
||||||
@@ -62,8 +126,3 @@ class APIHardware(CoreSysAttributes):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
|
||||||
def trigger(self, request: web.Request) -> Awaitable[None]:
|
|
||||||
"""Trigger a udev device reload."""
|
|
||||||
return asyncio.shield(self.sys_hardware.udev_trigger())
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -10,6 +12,8 @@ from ..const import (
|
|||||||
ATTR_ARCH,
|
ATTR_ARCH,
|
||||||
ATTR_AUDIO_INPUT,
|
ATTR_AUDIO_INPUT,
|
||||||
ATTR_AUDIO_OUTPUT,
|
ATTR_AUDIO_OUTPUT,
|
||||||
|
ATTR_BACKUP,
|
||||||
|
ATTR_BACKUPS_EXCLUDE_DATABASE,
|
||||||
ATTR_BLK_READ,
|
ATTR_BLK_READ,
|
||||||
ATTR_BLK_WRITE,
|
ATTR_BLK_WRITE,
|
||||||
ATTR_BOOT,
|
ATTR_BOOT,
|
||||||
@@ -28,14 +32,13 @@ from ..const import (
|
|||||||
ATTR_UPDATE_AVAILABLE,
|
ATTR_UPDATE_AVAILABLE,
|
||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
ATTR_VERSION_LATEST,
|
ATTR_VERSION_LATEST,
|
||||||
ATTR_WAIT_BOOT,
|
|
||||||
ATTR_WATCHDOG,
|
ATTR_WATCHDOG,
|
||||||
CONTENT_TYPE_BINARY,
|
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..exceptions import APIError
|
from ..exceptions import APIDBMigrationInProgress, APIError
|
||||||
from ..validate import docker_image, network_port, version_tag
|
from ..validate import docker_image, network_port, version_tag
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .const import ATTR_FORCE, ATTR_SAFE_MODE
|
||||||
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -43,25 +46,54 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
|||||||
SCHEMA_OPTIONS = vol.Schema(
|
SCHEMA_OPTIONS = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_BOOT): vol.Boolean(),
|
vol.Optional(ATTR_BOOT): vol.Boolean(),
|
||||||
vol.Optional(ATTR_IMAGE): docker_image,
|
vol.Optional(ATTR_IMAGE): vol.Maybe(docker_image),
|
||||||
vol.Optional(ATTR_PORT): network_port,
|
vol.Optional(ATTR_PORT): network_port,
|
||||||
vol.Optional(ATTR_SSL): vol.Boolean(),
|
vol.Optional(ATTR_SSL): vol.Boolean(),
|
||||||
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
||||||
vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)),
|
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(str),
|
||||||
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
|
||||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE): vol.Boolean(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
SCHEMA_UPDATE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_VERSION): version_tag,
|
||||||
|
vol.Optional(ATTR_BACKUP): bool,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_RESTART = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_SAFE_MODE, default=False): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_FORCE, default=False): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_STOP = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_FORCE, default=False): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class APIHomeAssistant(CoreSysAttributes):
|
class APIHomeAssistant(CoreSysAttributes):
|
||||||
"""Handle RESTful API for Home Assistant functions."""
|
"""Handle RESTful API for Home Assistant functions."""
|
||||||
|
|
||||||
|
async def _check_offline_migration(self, force: bool = False) -> None:
|
||||||
|
"""Check and raise if there's an offline DB migration in progress."""
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and (state := await self.sys_homeassistant.api.get_api_state())
|
||||||
|
and state.offline_db_migration
|
||||||
|
):
|
||||||
|
raise APIDBMigrationInProgress(
|
||||||
|
"Offline database migration in progress, try again after it has completed"
|
||||||
|
)
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return host information."""
|
"""Return host information."""
|
||||||
return {
|
return {
|
||||||
ATTR_VERSION: self.sys_homeassistant.version,
|
ATTR_VERSION: self.sys_homeassistant.version,
|
||||||
@@ -75,11 +107,9 @@ class APIHomeAssistant(CoreSysAttributes):
|
|||||||
ATTR_PORT: self.sys_homeassistant.api_port,
|
ATTR_PORT: self.sys_homeassistant.api_port,
|
||||||
ATTR_SSL: self.sys_homeassistant.api_ssl,
|
ATTR_SSL: self.sys_homeassistant.api_ssl,
|
||||||
ATTR_WATCHDOG: self.sys_homeassistant.watchdog,
|
ATTR_WATCHDOG: self.sys_homeassistant.watchdog,
|
||||||
ATTR_WAIT_BOOT: self.sys_homeassistant.wait_boot,
|
|
||||||
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
|
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
|
||||||
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
|
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
|
||||||
# Remove end of Q3 2020
|
ATTR_BACKUPS_EXCLUDE_DATABASE: self.sys_homeassistant.backups_exclude_database,
|
||||||
"last_version": self.sys_homeassistant.latest_version,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
@@ -89,6 +119,9 @@ class APIHomeAssistant(CoreSysAttributes):
|
|||||||
|
|
||||||
if ATTR_IMAGE in body:
|
if ATTR_IMAGE in body:
|
||||||
self.sys_homeassistant.image = body[ATTR_IMAGE]
|
self.sys_homeassistant.image = body[ATTR_IMAGE]
|
||||||
|
self.sys_homeassistant.override_image = (
|
||||||
|
self.sys_homeassistant.image != self.sys_homeassistant.default_image
|
||||||
|
)
|
||||||
|
|
||||||
if ATTR_BOOT in body:
|
if ATTR_BOOT in body:
|
||||||
self.sys_homeassistant.boot = body[ATTR_BOOT]
|
self.sys_homeassistant.boot = body[ATTR_BOOT]
|
||||||
@@ -102,9 +135,6 @@ class APIHomeAssistant(CoreSysAttributes):
|
|||||||
if ATTR_WATCHDOG in body:
|
if ATTR_WATCHDOG in body:
|
||||||
self.sys_homeassistant.watchdog = body[ATTR_WATCHDOG]
|
self.sys_homeassistant.watchdog = body[ATTR_WATCHDOG]
|
||||||
|
|
||||||
if ATTR_WAIT_BOOT in body:
|
|
||||||
self.sys_homeassistant.wait_boot = body[ATTR_WAIT_BOOT]
|
|
||||||
|
|
||||||
if ATTR_REFRESH_TOKEN in body:
|
if ATTR_REFRESH_TOKEN in body:
|
||||||
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
|
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
|
||||||
|
|
||||||
@@ -114,10 +144,15 @@ class APIHomeAssistant(CoreSysAttributes):
|
|||||||
if ATTR_AUDIO_OUTPUT in body:
|
if ATTR_AUDIO_OUTPUT in body:
|
||||||
self.sys_homeassistant.audio_output = body[ATTR_AUDIO_OUTPUT]
|
self.sys_homeassistant.audio_output = body[ATTR_AUDIO_OUTPUT]
|
||||||
|
|
||||||
|
if ATTR_BACKUPS_EXCLUDE_DATABASE in body:
|
||||||
|
self.sys_homeassistant.backups_exclude_database = body[
|
||||||
|
ATTR_BACKUPS_EXCLUDE_DATABASE
|
||||||
|
]
|
||||||
|
|
||||||
self.sys_homeassistant.save_data()
|
self.sys_homeassistant.save_data()
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stats(self, request: web.Request) -> Dict[Any, str]:
|
async def stats(self, request: web.Request) -> dict[Any, str]:
|
||||||
"""Return resource information."""
|
"""Return resource information."""
|
||||||
stats = await self.sys_homeassistant.core.stats()
|
stats = await self.sys_homeassistant.core.stats()
|
||||||
if not stats:
|
if not stats:
|
||||||
@@ -137,15 +172,23 @@ class APIHomeAssistant(CoreSysAttributes):
|
|||||||
@api_process
|
@api_process
|
||||||
async def update(self, request: web.Request) -> None:
|
async def update(self, request: web.Request) -> None:
|
||||||
"""Update Home Assistant."""
|
"""Update Home Assistant."""
|
||||||
body = await api_validate(SCHEMA_VERSION, request)
|
body = await api_validate(SCHEMA_UPDATE, request)
|
||||||
version = body.get(ATTR_VERSION, self.sys_homeassistant.latest_version)
|
await self._check_offline_migration()
|
||||||
|
|
||||||
await asyncio.shield(self.sys_homeassistant.core.update(version))
|
await asyncio.shield(
|
||||||
|
self.sys_homeassistant.core.update(
|
||||||
|
version=body.get(ATTR_VERSION, self.sys_homeassistant.latest_version),
|
||||||
|
backup=body.get(ATTR_BACKUP),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def stop(self, request: web.Request) -> Awaitable[None]:
|
async def stop(self, request: web.Request) -> Awaitable[None]:
|
||||||
"""Stop Home Assistant."""
|
"""Stop Home Assistant."""
|
||||||
return asyncio.shield(self.sys_homeassistant.core.stop())
|
body = await api_validate(SCHEMA_STOP, request)
|
||||||
|
await self._check_offline_migration(force=body[ATTR_FORCE])
|
||||||
|
|
||||||
|
return await asyncio.shield(self.sys_homeassistant.core.stop())
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def start(self, request: web.Request) -> Awaitable[None]:
|
def start(self, request: web.Request) -> Awaitable[None]:
|
||||||
@@ -153,19 +196,24 @@ class APIHomeAssistant(CoreSysAttributes):
|
|||||||
return asyncio.shield(self.sys_homeassistant.core.start())
|
return asyncio.shield(self.sys_homeassistant.core.start())
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def restart(self, request: web.Request) -> Awaitable[None]:
|
async def restart(self, request: web.Request) -> None:
|
||||||
"""Restart Home Assistant."""
|
"""Restart Home Assistant."""
|
||||||
return asyncio.shield(self.sys_homeassistant.core.restart())
|
body = await api_validate(SCHEMA_RESTART, request)
|
||||||
|
await self._check_offline_migration(force=body[ATTR_FORCE])
|
||||||
|
|
||||||
|
await asyncio.shield(
|
||||||
|
self.sys_homeassistant.core.restart(safe_mode=body[ATTR_SAFE_MODE])
|
||||||
|
)
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def rebuild(self, request: web.Request) -> Awaitable[None]:
|
async def rebuild(self, request: web.Request) -> None:
|
||||||
"""Rebuild Home Assistant."""
|
"""Rebuild Home Assistant."""
|
||||||
return asyncio.shield(self.sys_homeassistant.core.rebuild())
|
body = await api_validate(SCHEMA_RESTART, request)
|
||||||
|
await self._check_offline_migration(force=body[ATTR_FORCE])
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
await asyncio.shield(
|
||||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
self.sys_homeassistant.core.rebuild(safe_mode=body[ATTR_SAFE_MODE])
|
||||||
"""Return Home Assistant Docker logs."""
|
)
|
||||||
return self.sys_homeassistant.core.logs()
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def check(self, request: web.Request) -> None:
|
async def check(self, request: web.Request) -> None:
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
"""Init file for Supervisor host RESTful API."""
|
"""Init file for Supervisor host RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Awaitable
|
from contextlib import suppress
|
||||||
|
import logging
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from aiohttp.hdrs import ACCEPT, RANGE
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
from voluptuous.error import CoerceInvalid
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
ATTR_CHASSIS,
|
ATTR_CHASSIS,
|
||||||
@@ -11,6 +15,7 @@ from ..const import (
|
|||||||
ATTR_DEPLOYMENT,
|
ATTR_DEPLOYMENT,
|
||||||
ATTR_DESCRIPTON,
|
ATTR_DESCRIPTON,
|
||||||
ATTR_DISK_FREE,
|
ATTR_DISK_FREE,
|
||||||
|
ATTR_DISK_LIFE_TIME,
|
||||||
ATTR_DISK_TOTAL,
|
ATTR_DISK_TOTAL,
|
||||||
ATTR_DISK_USED,
|
ATTR_DISK_USED,
|
||||||
ATTR_FEATURES,
|
ATTR_FEATURES,
|
||||||
@@ -20,33 +25,96 @@ from ..const import (
|
|||||||
ATTR_OPERATING_SYSTEM,
|
ATTR_OPERATING_SYSTEM,
|
||||||
ATTR_SERVICES,
|
ATTR_SERVICES,
|
||||||
ATTR_STATE,
|
ATTR_STATE,
|
||||||
CONTENT_TYPE_BINARY,
|
ATTR_TIMEZONE,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
|
from ..exceptions import APIDBMigrationInProgress, APIError, HostLogError
|
||||||
|
from ..host.const import (
|
||||||
|
PARAM_BOOT_ID,
|
||||||
|
PARAM_FOLLOW,
|
||||||
|
PARAM_SYSLOG_IDENTIFIER,
|
||||||
|
LogFormat,
|
||||||
|
LogFormatter,
|
||||||
|
)
|
||||||
|
from ..utils.systemd_journal import journal_logs_reader
|
||||||
|
from .const import (
|
||||||
|
ATTR_AGENT_VERSION,
|
||||||
|
ATTR_APPARMOR_VERSION,
|
||||||
|
ATTR_BOOT_TIMESTAMP,
|
||||||
|
ATTR_BOOTS,
|
||||||
|
ATTR_BROADCAST_LLMNR,
|
||||||
|
ATTR_BROADCAST_MDNS,
|
||||||
|
ATTR_DT_SYNCHRONIZED,
|
||||||
|
ATTR_DT_UTC,
|
||||||
|
ATTR_FORCE,
|
||||||
|
ATTR_IDENTIFIERS,
|
||||||
|
ATTR_LLMNR_HOSTNAME,
|
||||||
|
ATTR_STARTUP_TIME,
|
||||||
|
ATTR_USE_NTP,
|
||||||
|
ATTR_VIRTUALIZATION,
|
||||||
|
CONTENT_TYPE_TEXT,
|
||||||
|
CONTENT_TYPE_X_LOG,
|
||||||
|
)
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .utils import api_process, api_process_raw, api_validate
|
||||||
|
|
||||||
SERVICE = "service"
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): vol.Coerce(str)})
|
IDENTIFIER = "identifier"
|
||||||
|
BOOTID = "bootid"
|
||||||
|
DEFAULT_LINES = 100
|
||||||
|
|
||||||
|
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): str})
|
||||||
|
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
|
SCHEMA_SHUTDOWN = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_FORCE, default=False): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# pylint: enable=no-value-for-parameter
|
||||||
|
|
||||||
|
|
||||||
class APIHost(CoreSysAttributes):
|
class APIHost(CoreSysAttributes):
|
||||||
"""Handle RESTful API for host functions."""
|
"""Handle RESTful API for host functions."""
|
||||||
|
|
||||||
|
async def _check_ha_offline_migration(self, force: bool) -> None:
|
||||||
|
"""Check if HA has an offline migration in progress and raise if not forced."""
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and (state := await self.sys_homeassistant.api.get_api_state())
|
||||||
|
and state.offline_db_migration
|
||||||
|
):
|
||||||
|
raise APIDBMigrationInProgress(
|
||||||
|
"Home Assistant offline database migration in progress, please wait until complete before shutting down host"
|
||||||
|
)
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request):
|
async def info(self, request):
|
||||||
"""Return host information."""
|
"""Return host information."""
|
||||||
return {
|
return {
|
||||||
|
ATTR_AGENT_VERSION: self.sys_dbus.agent.version,
|
||||||
|
ATTR_APPARMOR_VERSION: self.sys_host.apparmor.version,
|
||||||
ATTR_CHASSIS: self.sys_host.info.chassis,
|
ATTR_CHASSIS: self.sys_host.info.chassis,
|
||||||
|
ATTR_VIRTUALIZATION: self.sys_host.info.virtualization,
|
||||||
ATTR_CPE: self.sys_host.info.cpe,
|
ATTR_CPE: self.sys_host.info.cpe,
|
||||||
ATTR_DEPLOYMENT: self.sys_host.info.deployment,
|
ATTR_DEPLOYMENT: self.sys_host.info.deployment,
|
||||||
ATTR_DISK_FREE: self.sys_host.info.free_space,
|
ATTR_DISK_FREE: self.sys_host.info.free_space,
|
||||||
ATTR_DISK_TOTAL: self.sys_host.info.total_space,
|
ATTR_DISK_TOTAL: self.sys_host.info.total_space,
|
||||||
ATTR_DISK_USED: self.sys_host.info.used_space,
|
ATTR_DISK_USED: self.sys_host.info.used_space,
|
||||||
|
ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time,
|
||||||
ATTR_FEATURES: self.sys_host.features,
|
ATTR_FEATURES: self.sys_host.features,
|
||||||
ATTR_HOSTNAME: self.sys_host.info.hostname,
|
ATTR_HOSTNAME: self.sys_host.info.hostname,
|
||||||
|
ATTR_LLMNR_HOSTNAME: self.sys_host.info.llmnr_hostname,
|
||||||
ATTR_KERNEL: self.sys_host.info.kernel,
|
ATTR_KERNEL: self.sys_host.info.kernel,
|
||||||
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
|
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
|
||||||
|
ATTR_TIMEZONE: self.sys_host.info.timezone,
|
||||||
|
ATTR_DT_UTC: self.sys_host.info.dt_utc,
|
||||||
|
ATTR_DT_SYNCHRONIZED: self.sys_host.info.dt_synchronized,
|
||||||
|
ATTR_USE_NTP: self.sys_host.info.use_ntp,
|
||||||
|
ATTR_STARTUP_TIME: self.sys_host.info.startup_time,
|
||||||
|
ATTR_BOOT_TIMESTAMP: self.sys_host.info.boot_timestamp,
|
||||||
|
ATTR_BROADCAST_LLMNR: self.sys_host.info.broadcast_llmnr,
|
||||||
|
ATTR_BROADCAST_MDNS: self.sys_host.info.broadcast_mdns,
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
@@ -61,23 +129,25 @@ class APIHost(CoreSysAttributes):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def reboot(self, request):
|
async def reboot(self, request):
|
||||||
"""Reboot host."""
|
"""Reboot host."""
|
||||||
return asyncio.shield(self.sys_host.control.reboot())
|
body = await api_validate(SCHEMA_SHUTDOWN, request)
|
||||||
|
await self._check_ha_offline_migration(force=body[ATTR_FORCE])
|
||||||
|
|
||||||
|
return await asyncio.shield(self.sys_host.control.reboot())
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def shutdown(self, request):
|
async def shutdown(self, request):
|
||||||
"""Poweroff host."""
|
"""Poweroff host."""
|
||||||
return asyncio.shield(self.sys_host.control.shutdown())
|
body = await api_validate(SCHEMA_SHUTDOWN, request)
|
||||||
|
await self._check_ha_offline_migration(force=body[ATTR_FORCE])
|
||||||
|
|
||||||
|
return await asyncio.shield(self.sys_host.control.shutdown())
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def reload(self, request):
|
def reload(self, request):
|
||||||
"""Reload host data."""
|
"""Reload host data."""
|
||||||
return asyncio.shield(
|
return asyncio.shield(self.sys_host.reload())
|
||||||
asyncio.wait(
|
|
||||||
[self.sys_host.reload(), self.sys_resolution.evaluate.evaluate_system()]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def services(self, request):
|
async def services(self, request):
|
||||||
@@ -95,30 +165,111 @@ class APIHost(CoreSysAttributes):
|
|||||||
return {ATTR_SERVICES: services}
|
return {ATTR_SERVICES: services}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def service_start(self, request):
|
async def list_boots(self, _: web.Request):
|
||||||
"""Start a service."""
|
"""Return a list of boot IDs."""
|
||||||
unit = request.match_info.get(SERVICE)
|
boot_ids = await self.sys_host.logs.get_boot_ids()
|
||||||
return asyncio.shield(self.sys_host.services.start(unit))
|
return {
|
||||||
|
ATTR_BOOTS: {
|
||||||
|
str(1 + i - len(boot_ids)): boot_id
|
||||||
|
for i, boot_id in enumerate(boot_ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def service_stop(self, request):
|
async def list_identifiers(self, _: web.Request):
|
||||||
"""Stop a service."""
|
"""Return a list of syslog identifiers."""
|
||||||
unit = request.match_info.get(SERVICE)
|
return {ATTR_IDENTIFIERS: await self.sys_host.logs.get_identifiers()}
|
||||||
return asyncio.shield(self.sys_host.services.stop(unit))
|
|
||||||
|
|
||||||
@api_process
|
async def _get_boot_id(self, possible_offset: str) -> str:
|
||||||
def service_reload(self, request):
|
"""Convert offset into boot ID if required."""
|
||||||
"""Reload a service."""
|
with suppress(CoerceInvalid):
|
||||||
unit = request.match_info.get(SERVICE)
|
offset = vol.Coerce(int)(possible_offset)
|
||||||
return asyncio.shield(self.sys_host.services.reload(unit))
|
try:
|
||||||
|
return await self.sys_host.logs.get_boot_id(offset)
|
||||||
|
except (ValueError, HostLogError) as err:
|
||||||
|
raise APIError() from err
|
||||||
|
return possible_offset
|
||||||
|
|
||||||
@api_process
|
async def advanced_logs_handler(
|
||||||
def service_restart(self, request):
|
self, request: web.Request, identifier: str | None = None, follow: bool = False
|
||||||
"""Restart a service."""
|
) -> web.StreamResponse:
|
||||||
unit = request.match_info.get(SERVICE)
|
"""Return systemd-journald logs."""
|
||||||
return asyncio.shield(self.sys_host.services.restart(unit))
|
log_formatter = LogFormatter.PLAIN
|
||||||
|
params = {}
|
||||||
|
if identifier:
|
||||||
|
params[PARAM_SYSLOG_IDENTIFIER] = identifier
|
||||||
|
elif IDENTIFIER in request.match_info:
|
||||||
|
params[PARAM_SYSLOG_IDENTIFIER] = request.match_info.get(IDENTIFIER)
|
||||||
|
else:
|
||||||
|
params[PARAM_SYSLOG_IDENTIFIER] = self.sys_host.logs.default_identifiers
|
||||||
|
# host logs should be always verbose, no matter what Accept header is used
|
||||||
|
log_formatter = LogFormatter.VERBOSE
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
if BOOTID in request.match_info:
|
||||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
params[PARAM_BOOT_ID] = await self._get_boot_id(
|
||||||
"""Return host kernel logs."""
|
request.match_info.get(BOOTID)
|
||||||
return self.sys_host.info.get_dmesg()
|
)
|
||||||
|
if follow:
|
||||||
|
params[PARAM_FOLLOW] = ""
|
||||||
|
|
||||||
|
if ACCEPT in request.headers and request.headers[ACCEPT] not in [
|
||||||
|
CONTENT_TYPE_TEXT,
|
||||||
|
CONTENT_TYPE_X_LOG,
|
||||||
|
"*/*",
|
||||||
|
]:
|
||||||
|
raise APIError(
|
||||||
|
"Invalid content type requested. Only text/plain and text/x-log "
|
||||||
|
"supported for now."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "verbose" in request.query or request.headers[ACCEPT] == CONTENT_TYPE_X_LOG:
|
||||||
|
log_formatter = LogFormatter.VERBOSE
|
||||||
|
|
||||||
|
if "lines" in request.query:
|
||||||
|
lines = request.query.get("lines", DEFAULT_LINES)
|
||||||
|
try:
|
||||||
|
lines = int(lines)
|
||||||
|
except ValueError:
|
||||||
|
# If the user passed a non-integer value, just use the default instead of error.
|
||||||
|
lines = DEFAULT_LINES
|
||||||
|
finally:
|
||||||
|
# We can't use the entries= Range header syntax to refer to the last 1 line,
|
||||||
|
# and passing 1 to the calculation below would return the 1st line of the logs
|
||||||
|
# instead. Since this is really an edge case that doesn't matter much, we'll just
|
||||||
|
# return 2 lines at minimum.
|
||||||
|
lines = max(2, lines)
|
||||||
|
# entries=cursor[[:num_skip]:num_entries]
|
||||||
|
range_header = f"entries=:-{lines-1}:{'' if follow else lines}"
|
||||||
|
elif RANGE in request.headers:
|
||||||
|
range_header = request.headers.get(RANGE)
|
||||||
|
else:
|
||||||
|
range_header = (
|
||||||
|
f"entries=:-{DEFAULT_LINES-1}:{'' if follow else DEFAULT_LINES}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self.sys_host.logs.journald_logs(
|
||||||
|
params=params, range_header=range_header, accept=LogFormat.JOURNAL
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
response = web.StreamResponse()
|
||||||
|
response.content_type = CONTENT_TYPE_TEXT
|
||||||
|
headers_returned = False
|
||||||
|
async for cursor, line in journal_logs_reader(resp, log_formatter):
|
||||||
|
if not headers_returned:
|
||||||
|
if cursor:
|
||||||
|
response.headers["X-First-Cursor"] = cursor
|
||||||
|
await response.prepare(request)
|
||||||
|
headers_returned = True
|
||||||
|
await response.write(line.encode("utf-8") + b"\n")
|
||||||
|
except ConnectionResetError as ex:
|
||||||
|
raise APIError(
|
||||||
|
"Connection reset when trying to fetch data from systemd-journald."
|
||||||
|
) from ex
|
||||||
|
return response
|
||||||
|
|
||||||
|
@api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT)
|
||||||
|
async def advanced_logs(
|
||||||
|
self, request: web.Request, identifier: str | None = None, follow: bool = False
|
||||||
|
) -> web.StreamResponse:
|
||||||
|
"""Return systemd-journald logs. Wrapped as standard API handler."""
|
||||||
|
return await self.advanced_logs_handler(request, identifier, follow)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Init file for Supervisor info RESTful API."""
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
from ..const import (
|
|
||||||
ATTR_ARCH,
|
|
||||||
ATTR_CHANNEL,
|
|
||||||
ATTR_DOCKER,
|
|
||||||
ATTR_FEATURES,
|
|
||||||
ATTR_HASSOS,
|
|
||||||
ATTR_HOMEASSISTANT,
|
|
||||||
ATTR_HOSTNAME,
|
|
||||||
ATTR_LOGGING,
|
|
||||||
ATTR_MACHINE,
|
|
||||||
ATTR_OPERATING_SYSTEM,
|
|
||||||
ATTR_SUPERVISOR,
|
|
||||||
ATTR_SUPPORTED,
|
|
||||||
ATTR_SUPPORTED_ARCH,
|
|
||||||
ATTR_TIMEZONE,
|
|
||||||
)
|
|
||||||
from ..coresys import CoreSysAttributes
|
|
||||||
from .utils import api_process
|
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class APIInfo(CoreSysAttributes):
|
|
||||||
"""Handle RESTful API for info functions."""
|
|
||||||
|
|
||||||
@api_process
|
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
|
||||||
"""Show system info."""
|
|
||||||
return {
|
|
||||||
ATTR_SUPERVISOR: self.sys_supervisor.version,
|
|
||||||
ATTR_HOMEASSISTANT: self.sys_homeassistant.version,
|
|
||||||
ATTR_HASSOS: self.sys_hassos.version,
|
|
||||||
ATTR_DOCKER: self.sys_docker.info.version,
|
|
||||||
ATTR_HOSTNAME: self.sys_host.info.hostname,
|
|
||||||
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
|
|
||||||
ATTR_FEATURES: self.sys_host.features,
|
|
||||||
ATTR_MACHINE: self.sys_machine,
|
|
||||||
ATTR_ARCH: self.sys_arch.default,
|
|
||||||
ATTR_SUPPORTED_ARCH: self.sys_arch.supported,
|
|
||||||
ATTR_SUPPORTED: self.sys_core.supported,
|
|
||||||
ATTR_CHANNEL: self.sys_updater.channel,
|
|
||||||
ATTR_LOGGING: self.sys_config.logging,
|
|
||||||
ATTR_TIMEZONE: self.sys_config.timezone,
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Supervisor Add-on ingress service."""
|
"""Supervisor Add-on ingress service."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Union
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import hdrs, web
|
from aiohttp import ClientTimeout, hdrs, web
|
||||||
from aiohttp.web_exceptions import (
|
from aiohttp.web_exceptions import (
|
||||||
HTTPBadGateway,
|
HTTPBadGateway,
|
||||||
HTTPServiceUnavailable,
|
HTTPServiceUnavailable,
|
||||||
@@ -21,23 +22,65 @@ from ..const import (
|
|||||||
ATTR_ICON,
|
ATTR_ICON,
|
||||||
ATTR_PANELS,
|
ATTR_PANELS,
|
||||||
ATTR_SESSION,
|
ATTR_SESSION,
|
||||||
|
ATTR_SESSION_DATA_USER_ID,
|
||||||
ATTR_TITLE,
|
ATTR_TITLE,
|
||||||
COOKIE_INGRESS,
|
HEADER_REMOTE_USER_DISPLAY_NAME,
|
||||||
|
HEADER_REMOTE_USER_ID,
|
||||||
|
HEADER_REMOTE_USER_NAME,
|
||||||
HEADER_TOKEN,
|
HEADER_TOKEN,
|
||||||
HEADER_TOKEN_OLD,
|
HEADER_TOKEN_OLD,
|
||||||
REQUEST_FROM,
|
IngressSessionData,
|
||||||
|
IngressSessionDataUser,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from .utils import api_process, api_validate
|
from ..exceptions import HomeAssistantAPIError
|
||||||
|
from .const import COOKIE_INGRESS
|
||||||
|
from .utils import api_process, api_validate, require_home_assistant
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
VALIDATE_SESSION_DATA = vol.Schema({ATTR_SESSION: str})
|
VALIDATE_SESSION_DATA = vol.Schema({ATTR_SESSION: str})
|
||||||
|
|
||||||
|
"""Expected optional payload of create session request"""
|
||||||
|
SCHEMA_INGRESS_CREATE_SESSION_DATA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_SESSION_DATA_USER_ID): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# from https://github.com/aio-libs/aiohttp/blob/8ae650bee4add9f131d49b96a0a150311ea58cd1/aiohttp/helpers.py#L1059C1-L1079C1
|
||||||
|
def must_be_empty_body(method: str, code: int) -> bool:
|
||||||
|
"""Check if a request must return an empty body."""
|
||||||
|
return (
|
||||||
|
status_code_must_be_empty_body(code)
|
||||||
|
or method_must_be_empty_body(method)
|
||||||
|
or (200 <= code < 300 and method.upper() == hdrs.METH_CONNECT)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def method_must_be_empty_body(method: str) -> bool:
|
||||||
|
"""Check if a method must return an empty body."""
|
||||||
|
# https://datatracker.ietf.org/doc/html/rfc9112#section-6.3-2.1
|
||||||
|
# https://datatracker.ietf.org/doc/html/rfc9112#section-6.3-2.2
|
||||||
|
return method.upper() == hdrs.METH_HEAD
|
||||||
|
|
||||||
|
|
||||||
|
def status_code_must_be_empty_body(code: int) -> bool:
|
||||||
|
"""Check if a status code must return an empty body."""
|
||||||
|
# https://datatracker.ietf.org/doc/html/rfc9112#section-6.3-2.1
|
||||||
|
return code in {204, 304} or 100 <= code < 200
|
||||||
|
|
||||||
|
|
||||||
class APIIngress(CoreSysAttributes):
|
class APIIngress(CoreSysAttributes):
|
||||||
"""Ingress view to handle add-on webui routing."""
|
"""Ingress view to handle add-on webui routing."""
|
||||||
|
|
||||||
|
_list_of_users: list[IngressSessionDataUser]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize APIIngress."""
|
||||||
|
self._list_of_users = []
|
||||||
|
|
||||||
def _extract_addon(self, request: web.Request) -> Addon:
|
def _extract_addon(self, request: web.Request) -> Addon:
|
||||||
"""Return addon, throw an exception it it doesn't exist."""
|
"""Return addon, throw an exception it it doesn't exist."""
|
||||||
token = request.match_info.get("token")
|
token = request.match_info.get("token")
|
||||||
@@ -50,17 +93,12 @@ class APIIngress(CoreSysAttributes):
|
|||||||
|
|
||||||
return addon
|
return addon
|
||||||
|
|
||||||
def _check_ha_access(self, request: web.Request) -> None:
|
|
||||||
if request[REQUEST_FROM] != self.sys_homeassistant:
|
|
||||||
_LOGGER.warning("Ingress is only available behind Home Assistant")
|
|
||||||
raise HTTPUnauthorized()
|
|
||||||
|
|
||||||
def _create_url(self, addon: Addon, path: str) -> str:
|
def _create_url(self, addon: Addon, path: str) -> str:
|
||||||
"""Create URL to container."""
|
"""Create URL to container."""
|
||||||
return f"http://{addon.ip_address}:{addon.ingress_port}/{path}"
|
return f"http://{addon.ip_address}:{addon.ingress_port}/{path}"
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def panels(self, request: web.Request) -> Dict[str, Any]:
|
async def panels(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Create a list of panel data."""
|
"""Create a list of panel data."""
|
||||||
addons = {}
|
addons = {}
|
||||||
for addon in self.sys_ingress.addons:
|
for addon in self.sys_ingress.addons:
|
||||||
@@ -74,18 +112,28 @@ class APIIngress(CoreSysAttributes):
|
|||||||
return {ATTR_PANELS: addons}
|
return {ATTR_PANELS: addons}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def create_session(self, request: web.Request) -> Dict[str, Any]:
|
@require_home_assistant
|
||||||
|
async def create_session(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Create a new session."""
|
"""Create a new session."""
|
||||||
self._check_ha_access(request)
|
schema_ingress_config_session_data = await api_validate(
|
||||||
|
SCHEMA_INGRESS_CREATE_SESSION_DATA, request
|
||||||
|
)
|
||||||
|
data: IngressSessionData | None = None
|
||||||
|
|
||||||
session = self.sys_ingress.create_session()
|
if ATTR_SESSION_DATA_USER_ID in schema_ingress_config_session_data:
|
||||||
|
user = await self._find_user_by_id(
|
||||||
|
schema_ingress_config_session_data[ATTR_SESSION_DATA_USER_ID]
|
||||||
|
)
|
||||||
|
if user:
|
||||||
|
data = IngressSessionData(user)
|
||||||
|
|
||||||
|
session = self.sys_ingress.create_session(data)
|
||||||
return {ATTR_SESSION: session}
|
return {ATTR_SESSION: session}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def validate_session(self, request: web.Request) -> Dict[str, Any]:
|
@require_home_assistant
|
||||||
|
async def validate_session(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Validate session and extending how long it's valid for."""
|
"""Validate session and extending how long it's valid for."""
|
||||||
self._check_ha_access(request)
|
|
||||||
|
|
||||||
data = await api_validate(VALIDATE_SESSION_DATA, request)
|
data = await api_validate(VALIDATE_SESSION_DATA, request)
|
||||||
|
|
||||||
# Check Ingress Session
|
# Check Ingress Session
|
||||||
@@ -95,9 +143,8 @@ class APIIngress(CoreSysAttributes):
|
|||||||
|
|
||||||
async def handler(
|
async def handler(
|
||||||
self, request: web.Request
|
self, request: web.Request
|
||||||
) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
|
) -> web.Response | web.StreamResponse | web.WebSocketResponse:
|
||||||
"""Route data to Supervisor ingress service."""
|
"""Route data to Supervisor ingress service."""
|
||||||
self._check_ha_access(request)
|
|
||||||
|
|
||||||
# Check Ingress Session
|
# Check Ingress Session
|
||||||
session = request.cookies.get(COOKIE_INGRESS)
|
session = request.cookies.get(COOKIE_INGRESS)
|
||||||
@@ -108,13 +155,14 @@ class APIIngress(CoreSysAttributes):
|
|||||||
# Process requests
|
# Process requests
|
||||||
addon = self._extract_addon(request)
|
addon = self._extract_addon(request)
|
||||||
path = request.match_info.get("path")
|
path = request.match_info.get("path")
|
||||||
|
session_data = self.sys_ingress.get_session_data(session)
|
||||||
try:
|
try:
|
||||||
# Websocket
|
# Websocket
|
||||||
if _is_websocket(request):
|
if _is_websocket(request):
|
||||||
return await self._handle_websocket(request, addon, path)
|
return await self._handle_websocket(request, addon, path, session_data)
|
||||||
|
|
||||||
# Request
|
# Request
|
||||||
return await self._handle_request(request, addon, path)
|
return await self._handle_request(request, addon, path, session_data)
|
||||||
|
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.error("Ingress error: %s", err)
|
_LOGGER.error("Ingress error: %s", err)
|
||||||
@@ -122,7 +170,11 @@ class APIIngress(CoreSysAttributes):
|
|||||||
raise HTTPBadGateway()
|
raise HTTPBadGateway()
|
||||||
|
|
||||||
async def _handle_websocket(
|
async def _handle_websocket(
|
||||||
self, request: web.Request, addon: Addon, path: str
|
self,
|
||||||
|
request: web.Request,
|
||||||
|
addon: Addon,
|
||||||
|
path: str,
|
||||||
|
session_data: IngressSessionData | None,
|
||||||
) -> web.WebSocketResponse:
|
) -> web.WebSocketResponse:
|
||||||
"""Ingress route for websocket."""
|
"""Ingress route for websocket."""
|
||||||
if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
|
if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
|
||||||
@@ -140,7 +192,7 @@ class APIIngress(CoreSysAttributes):
|
|||||||
|
|
||||||
# Preparing
|
# Preparing
|
||||||
url = self._create_url(addon, path)
|
url = self._create_url(addon, path)
|
||||||
source_header = _init_header(request, addon)
|
source_header = _init_header(request, addon, session_data)
|
||||||
|
|
||||||
# Support GET query
|
# Support GET query
|
||||||
if request.query_string:
|
if request.query_string:
|
||||||
@@ -157,8 +209,8 @@ class APIIngress(CoreSysAttributes):
|
|||||||
# Proxy requests
|
# Proxy requests
|
||||||
await asyncio.wait(
|
await asyncio.wait(
|
||||||
[
|
[
|
||||||
_websocket_forward(ws_server, ws_client),
|
self.sys_create_task(_websocket_forward(ws_server, ws_client)),
|
||||||
_websocket_forward(ws_client, ws_server),
|
self.sys_create_task(_websocket_forward(ws_client, ws_server)),
|
||||||
],
|
],
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
@@ -166,12 +218,25 @@ class APIIngress(CoreSysAttributes):
|
|||||||
return ws_server
|
return ws_server
|
||||||
|
|
||||||
async def _handle_request(
|
async def _handle_request(
|
||||||
self, request: web.Request, addon: Addon, path: str
|
self,
|
||||||
) -> Union[web.Response, web.StreamResponse]:
|
request: web.Request,
|
||||||
|
addon: Addon,
|
||||||
|
path: str,
|
||||||
|
session_data: IngressSessionData | None,
|
||||||
|
) -> web.Response | web.StreamResponse:
|
||||||
"""Ingress route for request."""
|
"""Ingress route for request."""
|
||||||
url = self._create_url(addon, path)
|
url = self._create_url(addon, path)
|
||||||
data = await request.read()
|
source_header = _init_header(request, addon, session_data)
|
||||||
source_header = _init_header(request, addon)
|
|
||||||
|
# Passing the raw stream breaks requests for some webservers
|
||||||
|
# since we just need it for POST requests really, for all other methods
|
||||||
|
# we read the bytes and pass that to the request to the add-on
|
||||||
|
# add-ons needs to add support with that in the configuration
|
||||||
|
data = (
|
||||||
|
request.content
|
||||||
|
if request.method == "POST" and addon.ingress_stream
|
||||||
|
else await request.read()
|
||||||
|
)
|
||||||
|
|
||||||
async with self.sys_websession.request(
|
async with self.sys_websession.request(
|
||||||
request.method,
|
request.method,
|
||||||
@@ -180,12 +245,22 @@ class APIIngress(CoreSysAttributes):
|
|||||||
params=request.query,
|
params=request.query,
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
data=data,
|
data=data,
|
||||||
|
timeout=ClientTimeout(total=None),
|
||||||
|
skip_auto_headers={hdrs.CONTENT_TYPE},
|
||||||
) as result:
|
) as result:
|
||||||
headers = _response_header(result)
|
headers = _response_header(result)
|
||||||
|
# Avoid parsing content_type in simple cases for better performance
|
||||||
|
if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE):
|
||||||
|
content_type = (maybe_content_type.partition(";"))[0].strip()
|
||||||
|
else:
|
||||||
|
content_type = result.content_type
|
||||||
# Simple request
|
# Simple request
|
||||||
if (
|
if (
|
||||||
hdrs.CONTENT_LENGTH in result.headers
|
# empty body responses should not be streamed,
|
||||||
|
# otherwise aiohttp < 3.9.0 may generate
|
||||||
|
# an invalid "0\r\n\r\n" chunk instead of an empty response.
|
||||||
|
must_be_empty_body(request.method, result.status)
|
||||||
|
or hdrs.CONTENT_LENGTH in result.headers
|
||||||
and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4_194_000
|
and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4_194_000
|
||||||
):
|
):
|
||||||
# Return Response
|
# Return Response
|
||||||
@@ -193,13 +268,13 @@ class APIIngress(CoreSysAttributes):
|
|||||||
return web.Response(
|
return web.Response(
|
||||||
headers=headers,
|
headers=headers,
|
||||||
status=result.status,
|
status=result.status,
|
||||||
content_type=result.content_type,
|
content_type=content_type,
|
||||||
body=body,
|
body=body,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream response
|
# Stream response
|
||||||
response = web.StreamResponse(status=result.status, headers=headers)
|
response = web.StreamResponse(status=result.status, headers=headers)
|
||||||
response.content_type = result.content_type
|
response.content_type = content_type
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await response.prepare(request)
|
await response.prepare(request)
|
||||||
@@ -215,24 +290,50 @@ class APIIngress(CoreSysAttributes):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
async def _find_user_by_id(self, user_id: str) -> IngressSessionDataUser | None:
|
||||||
|
"""Find user object by the user's ID."""
|
||||||
|
try:
|
||||||
|
list_of_users = await self.sys_homeassistant.get_users()
|
||||||
|
except (HomeAssistantAPIError, TypeError) as err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"%s error occurred while requesting list of users: %s", type(err), err
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if list_of_users is not None:
|
||||||
|
self._list_of_users = list_of_users
|
||||||
|
|
||||||
|
return next((user for user in self._list_of_users if user.id == user_id), None)
|
||||||
|
|
||||||
|
|
||||||
def _init_header(
|
def _init_header(
|
||||||
request: web.Request, addon: str
|
request: web.Request, addon: Addon, session_data: IngressSessionData | None
|
||||||
) -> Union[CIMultiDict, Dict[str, str]]:
|
) -> CIMultiDict | dict[str, str]:
|
||||||
"""Create initial header."""
|
"""Create initial header."""
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
|
if session_data is not None:
|
||||||
|
headers[HEADER_REMOTE_USER_ID] = session_data.user.id
|
||||||
|
if session_data.user.username is not None:
|
||||||
|
headers[HEADER_REMOTE_USER_NAME] = session_data.user.username
|
||||||
|
if session_data.user.display_name is not None:
|
||||||
|
headers[HEADER_REMOTE_USER_DISPLAY_NAME] = session_data.user.display_name
|
||||||
|
|
||||||
# filter flags
|
# filter flags
|
||||||
for name, value in request.headers.items():
|
for name, value in request.headers.items():
|
||||||
if name in (
|
if name in (
|
||||||
hdrs.CONTENT_LENGTH,
|
hdrs.CONTENT_LENGTH,
|
||||||
hdrs.CONTENT_ENCODING,
|
hdrs.CONTENT_ENCODING,
|
||||||
|
hdrs.TRANSFER_ENCODING,
|
||||||
hdrs.SEC_WEBSOCKET_EXTENSIONS,
|
hdrs.SEC_WEBSOCKET_EXTENSIONS,
|
||||||
hdrs.SEC_WEBSOCKET_PROTOCOL,
|
hdrs.SEC_WEBSOCKET_PROTOCOL,
|
||||||
hdrs.SEC_WEBSOCKET_VERSION,
|
hdrs.SEC_WEBSOCKET_VERSION,
|
||||||
hdrs.SEC_WEBSOCKET_KEY,
|
hdrs.SEC_WEBSOCKET_KEY,
|
||||||
istr(HEADER_TOKEN),
|
istr(HEADER_TOKEN),
|
||||||
istr(HEADER_TOKEN_OLD),
|
istr(HEADER_TOKEN_OLD),
|
||||||
|
istr(HEADER_REMOTE_USER_ID),
|
||||||
|
istr(HEADER_REMOTE_USER_NAME),
|
||||||
|
istr(HEADER_REMOTE_USER_DISPLAY_NAME),
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
headers[name] = value
|
headers[name] = value
|
||||||
@@ -245,7 +346,7 @@ def _init_header(
|
|||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
||||||
def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
|
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
|
||||||
"""Create response header."""
|
"""Create response header."""
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""Init file for Supervisor Jobs RESTful API."""
|
"""Init file for Supervisor Jobs RESTful API."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
|
from ..exceptions import APIError
|
||||||
|
from ..jobs import SupervisorJob
|
||||||
from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition
|
from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition
|
||||||
|
from .const import ATTR_JOBS
|
||||||
from .utils import api_process, api_validate
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
@@ -19,11 +23,47 @@ SCHEMA_OPTIONS = vol.Schema(
|
|||||||
class APIJobs(CoreSysAttributes):
|
class APIJobs(CoreSysAttributes):
|
||||||
"""Handle RESTful API for OS functions."""
|
"""Handle RESTful API for OS functions."""
|
||||||
|
|
||||||
|
def _list_jobs(self, start: SupervisorJob | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""Return current job tree."""
|
||||||
|
jobs_by_parent: dict[str | None, list[SupervisorJob]] = {}
|
||||||
|
for job in self.sys_jobs.jobs:
|
||||||
|
if job.internal:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if job.parent_id not in jobs_by_parent:
|
||||||
|
jobs_by_parent[job.parent_id] = [job]
|
||||||
|
else:
|
||||||
|
jobs_by_parent[job.parent_id].append(job)
|
||||||
|
|
||||||
|
job_list: list[dict[str, Any]] = []
|
||||||
|
queue: list[tuple[list[dict[str, Any]], SupervisorJob]] = (
|
||||||
|
[(job_list, start)]
|
||||||
|
if start
|
||||||
|
else [(job_list, job) for job in jobs_by_parent.get(None, [])]
|
||||||
|
)
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
(current_list, current_job) = queue.pop(0)
|
||||||
|
child_jobs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# We remove parent_id and instead use that info to represent jobs as a tree
|
||||||
|
job_dict = current_job.as_dict() | {"child_jobs": child_jobs}
|
||||||
|
job_dict.pop("parent_id")
|
||||||
|
current_list.append(job_dict)
|
||||||
|
|
||||||
|
if current_job.uuid in jobs_by_parent:
|
||||||
|
queue.extend(
|
||||||
|
[(child_jobs, job) for job in jobs_by_parent.get(current_job.uuid)]
|
||||||
|
)
|
||||||
|
|
||||||
|
return job_list
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return JobManager information."""
|
"""Return JobManager information."""
|
||||||
return {
|
return {
|
||||||
ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions,
|
ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions,
|
||||||
|
ATTR_JOBS: self._list_jobs(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
@@ -36,7 +76,25 @@ class APIJobs(CoreSysAttributes):
|
|||||||
|
|
||||||
self.sys_jobs.save_data()
|
self.sys_jobs.save_data()
|
||||||
|
|
||||||
|
await self.sys_resolution.evaluate.evaluate_system()
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def reset(self, request: web.Request) -> None:
|
async def reset(self, request: web.Request) -> None:
|
||||||
"""Reset options for JobManager."""
|
"""Reset options for JobManager."""
|
||||||
self.sys_jobs.reset_data()
|
self.sys_jobs.reset_data()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def job_info(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Get details of a job by ID."""
|
||||||
|
job = self.sys_jobs.get_job(request.match_info.get("uuid"))
|
||||||
|
return self._list_jobs(job)[0]
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def remove_job(self, request: web.Request) -> None:
|
||||||
|
"""Remove a completed job."""
|
||||||
|
job = self.sys_jobs.get_job(request.match_info.get("uuid"))
|
||||||
|
|
||||||
|
if not job.done:
|
||||||
|
raise APIError(f"Job {job.uuid} is not done!")
|
||||||
|
|
||||||
|
self.sys_jobs.remove_job(job)
|
||||||
|
|||||||
1
supervisor/api/middleware/__init__.py
Normal file
1
supervisor/api/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API middleware for aiohttp."""
|
||||||
331
supervisor/api/middleware/security.py
Normal file
331
supervisor/api/middleware/security.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""Handle security part of this API."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Final
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from aiohttp.web import Request, RequestHandler, Response, middleware
|
||||||
|
from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized
|
||||||
|
from awesomeversion import AwesomeVersion
|
||||||
|
|
||||||
|
from supervisor.homeassistant.const import LANDINGPAGE
|
||||||
|
|
||||||
|
from ...addons.const import RE_SLUG
|
||||||
|
from ...const import (
|
||||||
|
REQUEST_FROM,
|
||||||
|
ROLE_ADMIN,
|
||||||
|
ROLE_BACKUP,
|
||||||
|
ROLE_DEFAULT,
|
||||||
|
ROLE_HOMEASSISTANT,
|
||||||
|
ROLE_MANAGER,
|
||||||
|
CoreState,
|
||||||
|
)
|
||||||
|
from ...coresys import CoreSys, CoreSysAttributes
|
||||||
|
from ...utils import version_is_new_enough
|
||||||
|
from ..utils import api_return_error, excract_supervisor_token
|
||||||
|
|
||||||
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
_CORE_VERSION: Final = AwesomeVersion("2023.3.4")
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
|
||||||
|
_CORE_FRONTEND_PATHS: Final = (
|
||||||
|
r"|/app/.*\.(?:js|gz|json|map|woff2)"
|
||||||
|
r"|/(store/)?addons/" + RE_SLUG + r"/(logo|icon)"
|
||||||
|
)
|
||||||
|
|
||||||
|
CORE_FRONTEND: Final = re.compile(
|
||||||
|
r"^(?:" + _CORE_FRONTEND_PATHS + r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Block Anytime
|
||||||
|
BLACKLIST: Final = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|/homeassistant/api/hassio/.*"
|
||||||
|
r"|/core/api/hassio/.*"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Free to call or have own security concepts
|
||||||
|
NO_SECURITY_CHECK: Final = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|/homeassistant/api/.*"
|
||||||
|
r"|/homeassistant/websocket"
|
||||||
|
r"|/core/api/.*"
|
||||||
|
r"|/core/websocket"
|
||||||
|
r"|/supervisor/ping"
|
||||||
|
r"|/ingress/[-_A-Za-z0-9]+/.*"
|
||||||
|
+ _CORE_FRONTEND_PATHS
|
||||||
|
+ r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Observer allow API calls
|
||||||
|
OBSERVER_CHECK: Final = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|/.+/info"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Can called by every add-on
|
||||||
|
ADDONS_API_BYPASS: Final = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|/addons/self/(?!security|update)[^/]+"
|
||||||
|
r"|/addons/self/options/config"
|
||||||
|
r"|/info"
|
||||||
|
r"|/services.*"
|
||||||
|
r"|/discovery.*"
|
||||||
|
r"|/auth"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Home Assistant only
|
||||||
|
CORE_ONLY_PATHS: Final = re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"/addons/" + RE_SLUG + "/sys_options"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Policy role add-on API access
|
||||||
|
ADDONS_ROLE_ACCESS: dict[str, re.Pattern] = {
|
||||||
|
ROLE_DEFAULT: re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|/.+/info"
|
||||||
|
r")$"
|
||||||
|
),
|
||||||
|
ROLE_HOMEASSISTANT: re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|/.+/info"
|
||||||
|
r"|/core/.+"
|
||||||
|
r"|/homeassistant/.+"
|
||||||
|
r")$"
|
||||||
|
),
|
||||||
|
ROLE_BACKUP: re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|/.+/info"
|
||||||
|
r"|/backups.*"
|
||||||
|
r")$"
|
||||||
|
),
|
||||||
|
ROLE_MANAGER: re.compile(
|
||||||
|
r"^(?:"
|
||||||
|
r"|/.+/info"
|
||||||
|
r"|/addons(?:/" + RE_SLUG + r"/(?!security).+|/reload)?"
|
||||||
|
r"|/audio/.+"
|
||||||
|
r"|/auth/cache"
|
||||||
|
r"|/available_updates"
|
||||||
|
r"|/backups.*"
|
||||||
|
r"|/cli/.+"
|
||||||
|
r"|/core/.+"
|
||||||
|
r"|/dns/.+"
|
||||||
|
r"|/docker/.+"
|
||||||
|
r"|/jobs/.+"
|
||||||
|
r"|/hardware/.+"
|
||||||
|
r"|/hassos/.+"
|
||||||
|
r"|/homeassistant/.+"
|
||||||
|
r"|/host/.+"
|
||||||
|
r"|/mounts.*"
|
||||||
|
r"|/multicast/.+"
|
||||||
|
r"|/network/.+"
|
||||||
|
r"|/observer/.+"
|
||||||
|
r"|/os/(?!datadisk/wipe).+"
|
||||||
|
r"|/refresh_updates"
|
||||||
|
r"|/resolution/.+"
|
||||||
|
r"|/security/.+"
|
||||||
|
r"|/snapshots.*"
|
||||||
|
r"|/store.*"
|
||||||
|
r"|/supervisor/.+"
|
||||||
|
r")$"
|
||||||
|
),
|
||||||
|
ROLE_ADMIN: re.compile(
|
||||||
|
r".*"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
FILTERS: Final = re.compile(
|
||||||
|
r"(?:"
|
||||||
|
|
||||||
|
# Common exploits
|
||||||
|
r"proc/self/environ"
|
||||||
|
r"|(<|%3C).*script.*(>|%3E)"
|
||||||
|
|
||||||
|
# File Injections
|
||||||
|
r"|(\.\.//?)+" # ../../anywhere
|
||||||
|
r"|[a-zA-Z0-9_]=/([a-z0-9_.]//?)+" # .html?v=/.//test
|
||||||
|
|
||||||
|
# SQL Injections
|
||||||
|
r"|union.*select.*\("
|
||||||
|
r"|union.*all.*select.*"
|
||||||
|
r"|concat.*\("
|
||||||
|
|
||||||
|
r")",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityMiddleware(CoreSysAttributes):
|
||||||
|
"""Security middleware functions."""
|
||||||
|
|
||||||
|
def __init__(self, coresys: CoreSys):
|
||||||
|
"""Initialize security middleware."""
|
||||||
|
self.coresys: CoreSys = coresys
|
||||||
|
|
||||||
|
def _recursive_unquote(self, value: str) -> str:
|
||||||
|
"""Handle values that are encoded multiple times."""
|
||||||
|
if (unquoted := unquote(value)) != value:
|
||||||
|
unquoted = self._recursive_unquote(unquoted)
|
||||||
|
return unquoted
|
||||||
|
|
||||||
|
@middleware
|
||||||
|
async def block_bad_requests(
|
||||||
|
self, request: Request, handler: RequestHandler
|
||||||
|
) -> Response:
|
||||||
|
"""Process request and tblock commonly known exploit attempts."""
|
||||||
|
if FILTERS.search(self._recursive_unquote(request.path)):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Filtered a potential harmful request to: %s", request.raw_path
|
||||||
|
)
|
||||||
|
raise HTTPBadRequest
|
||||||
|
|
||||||
|
if FILTERS.search(self._recursive_unquote(request.query_string)):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Filtered a request with a potential harmful query string: %s",
|
||||||
|
request.raw_path,
|
||||||
|
)
|
||||||
|
raise HTTPBadRequest
|
||||||
|
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
@middleware
|
||||||
|
async def system_validation(
|
||||||
|
self, request: Request, handler: RequestHandler
|
||||||
|
) -> Response:
|
||||||
|
"""Check if core is ready to response."""
|
||||||
|
if self.sys_core.state not in (
|
||||||
|
CoreState.STARTUP,
|
||||||
|
CoreState.RUNNING,
|
||||||
|
CoreState.FREEZE,
|
||||||
|
):
|
||||||
|
return api_return_error(
|
||||||
|
message=f"System is not ready with state: {self.sys_core.state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
@middleware
|
||||||
|
async def token_validation(
|
||||||
|
self, request: Request, handler: RequestHandler
|
||||||
|
) -> Response:
|
||||||
|
"""Check security access of this layer."""
|
||||||
|
request_from = None
|
||||||
|
supervisor_token = excract_supervisor_token(request)
|
||||||
|
|
||||||
|
# Blacklist
|
||||||
|
if BLACKLIST.match(request.path):
|
||||||
|
_LOGGER.error("%s is blacklisted!", request.path)
|
||||||
|
raise HTTPForbidden()
|
||||||
|
|
||||||
|
# Ignore security check
|
||||||
|
if NO_SECURITY_CHECK.match(request.path):
|
||||||
|
_LOGGER.debug("Passthrough %s", request.path)
|
||||||
|
request[REQUEST_FROM] = None
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
# Not token
|
||||||
|
if not supervisor_token:
|
||||||
|
_LOGGER.warning("No API token provided for %s", request.path)
|
||||||
|
raise HTTPUnauthorized()
|
||||||
|
|
||||||
|
# Home-Assistant
|
||||||
|
if supervisor_token == self.sys_homeassistant.supervisor_token:
|
||||||
|
_LOGGER.debug("%s access from Home Assistant", request.path)
|
||||||
|
request_from = self.sys_homeassistant
|
||||||
|
elif CORE_ONLY_PATHS.match(request.path):
|
||||||
|
_LOGGER.warning("Attempted access to %s from client besides Home Assistant")
|
||||||
|
raise HTTPForbidden()
|
||||||
|
|
||||||
|
# Host
|
||||||
|
if supervisor_token == self.sys_plugins.cli.supervisor_token:
|
||||||
|
_LOGGER.debug("%s access from Host", request.path)
|
||||||
|
request_from = self.sys_host
|
||||||
|
|
||||||
|
# Observer
|
||||||
|
if supervisor_token == self.sys_plugins.observer.supervisor_token:
|
||||||
|
if not OBSERVER_CHECK.match(request.path):
|
||||||
|
_LOGGER.warning("%s invalid Observer access", request.path)
|
||||||
|
raise HTTPForbidden()
|
||||||
|
_LOGGER.debug("%s access from Observer", request.path)
|
||||||
|
request_from = self.sys_plugins.observer
|
||||||
|
|
||||||
|
# Add-on
|
||||||
|
addon = None
|
||||||
|
if supervisor_token and not request_from:
|
||||||
|
addon = self.sys_addons.from_token(supervisor_token)
|
||||||
|
|
||||||
|
# Check Add-on API access
|
||||||
|
if addon and ADDONS_API_BYPASS.match(request.path):
|
||||||
|
_LOGGER.debug("Passthrough %s from %s", request.path, addon.slug)
|
||||||
|
request_from = addon
|
||||||
|
elif addon and addon.access_hassio_api:
|
||||||
|
# Check Role
|
||||||
|
if ADDONS_ROLE_ACCESS[addon.hassio_role].match(request.path):
|
||||||
|
_LOGGER.info("%s access from %s", request.path, addon.slug)
|
||||||
|
request_from = addon
|
||||||
|
else:
|
||||||
|
_LOGGER.warning("%s no role for %s", request.path, addon.slug)
|
||||||
|
elif addon:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s missing API permission for %s", addon.slug, request.path
|
||||||
|
)
|
||||||
|
|
||||||
|
if request_from:
|
||||||
|
request[REQUEST_FROM] = request_from
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
_LOGGER.error("Invalid token for access %s", request.path)
|
||||||
|
raise HTTPForbidden()
|
||||||
|
|
||||||
|
@middleware
|
||||||
|
async def core_proxy(self, request: Request, handler: RequestHandler) -> Response:
|
||||||
|
"""Validate user from Core API proxy."""
|
||||||
|
if (
|
||||||
|
request[REQUEST_FROM] != self.sys_homeassistant
|
||||||
|
or self.sys_homeassistant.version == LANDINGPAGE
|
||||||
|
or version_is_new_enough(self.sys_homeassistant.version, _CORE_VERSION)
|
||||||
|
):
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
authorization_index: int | None = None
|
||||||
|
content_type_index: int | None = None
|
||||||
|
user_request: bool = False
|
||||||
|
admin_request: bool = False
|
||||||
|
ingress_request: bool = False
|
||||||
|
|
||||||
|
for idx, (key, value) in enumerate(request.raw_headers):
|
||||||
|
if key in (b"Authorization", b"X-Hassio-Key"):
|
||||||
|
authorization_index = idx
|
||||||
|
elif key == b"Content-Type":
|
||||||
|
content_type_index = idx
|
||||||
|
elif key == b"X-Hass-User-ID":
|
||||||
|
user_request = True
|
||||||
|
elif key == b"X-Hass-Is-Admin":
|
||||||
|
admin_request = value == b"1"
|
||||||
|
elif key == b"X-Ingress-Path":
|
||||||
|
ingress_request = True
|
||||||
|
|
||||||
|
if (user_request or admin_request) and not ingress_request:
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
is_proxy_request = (
|
||||||
|
authorization_index is not None
|
||||||
|
and content_type_index is not None
|
||||||
|
and content_type_index - authorization_index == 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not CORE_FRONTEND.match(request.path) and is_proxy_request
|
||||||
|
) or ingress_request:
|
||||||
|
raise HTTPBadRequest()
|
||||||
|
return await handler(request)
|
||||||
124
supervisor/api/mounts.py
Normal file
124
supervisor/api/mounts.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Inits file for supervisor mounts REST API."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from ..const import ATTR_NAME, ATTR_STATE
|
||||||
|
from ..coresys import CoreSysAttributes
|
||||||
|
from ..exceptions import APIError
|
||||||
|
from ..mounts.const import ATTR_DEFAULT_BACKUP_MOUNT, MountUsage
|
||||||
|
from ..mounts.mount import Mount
|
||||||
|
from ..mounts.validate import SCHEMA_MOUNT_CONFIG
|
||||||
|
from .const import ATTR_MOUNTS
|
||||||
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
|
SCHEMA_OPTIONS = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_DEFAULT_BACKUP_MOUNT): vol.Maybe(str),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class APIMounts(CoreSysAttributes):
|
||||||
|
"""Handle REST API for mounting options."""
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Return MountManager info."""
|
||||||
|
return {
|
||||||
|
ATTR_DEFAULT_BACKUP_MOUNT: self.sys_mounts.default_backup_mount.name
|
||||||
|
if self.sys_mounts.default_backup_mount
|
||||||
|
else None,
|
||||||
|
ATTR_MOUNTS: [
|
||||||
|
mount.to_dict() | {ATTR_STATE: mount.state}
|
||||||
|
for mount in self.sys_mounts.mounts
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def options(self, request: web.Request) -> None:
|
||||||
|
"""Set Mount Manager options."""
|
||||||
|
body = await api_validate(SCHEMA_OPTIONS, request)
|
||||||
|
|
||||||
|
if ATTR_DEFAULT_BACKUP_MOUNT in body:
|
||||||
|
name: str | None = body[ATTR_DEFAULT_BACKUP_MOUNT]
|
||||||
|
if name is None:
|
||||||
|
self.sys_mounts.default_backup_mount = None
|
||||||
|
elif (mount := self.sys_mounts.get(name)).usage != MountUsage.BACKUP:
|
||||||
|
raise APIError(
|
||||||
|
f"Mount {name} is not used for backups, cannot use it as default backup mount"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.sys_mounts.default_backup_mount = mount
|
||||||
|
|
||||||
|
self.sys_mounts.save_data()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def create_mount(self, request: web.Request) -> None:
|
||||||
|
"""Create a new mount in supervisor."""
|
||||||
|
body = await api_validate(SCHEMA_MOUNT_CONFIG, request)
|
||||||
|
|
||||||
|
if body[ATTR_NAME] in self.sys_mounts:
|
||||||
|
raise APIError(f"A mount already exists with name {body[ATTR_NAME]}")
|
||||||
|
|
||||||
|
mount = Mount.from_dict(self.coresys, body)
|
||||||
|
await self.sys_mounts.create_mount(mount)
|
||||||
|
|
||||||
|
# If it's a backup mount, reload backups
|
||||||
|
if mount.usage == MountUsage.BACKUP:
|
||||||
|
self.sys_create_task(self.sys_backups.reload())
|
||||||
|
|
||||||
|
# If there's no default backup mount, set it to the new mount
|
||||||
|
if not self.sys_mounts.default_backup_mount:
|
||||||
|
self.sys_mounts.default_backup_mount = mount
|
||||||
|
|
||||||
|
self.sys_mounts.save_data()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def update_mount(self, request: web.Request) -> None:
|
||||||
|
"""Update an existing mount in supervisor."""
|
||||||
|
name = request.match_info.get("mount")
|
||||||
|
name_schema = vol.Schema(
|
||||||
|
{vol.Optional(ATTR_NAME, default=name): name}, extra=vol.ALLOW_EXTRA
|
||||||
|
)
|
||||||
|
body = await api_validate(vol.All(name_schema, SCHEMA_MOUNT_CONFIG), request)
|
||||||
|
|
||||||
|
if name not in self.sys_mounts:
|
||||||
|
raise APIError(f"No mount exists with name {name}")
|
||||||
|
|
||||||
|
mount = Mount.from_dict(self.coresys, body)
|
||||||
|
await self.sys_mounts.create_mount(mount)
|
||||||
|
|
||||||
|
# If it's a backup mount, reload backups
|
||||||
|
if mount.usage == MountUsage.BACKUP:
|
||||||
|
self.sys_create_task(self.sys_backups.reload())
|
||||||
|
|
||||||
|
# If this mount was the default backup mount and isn't for backups any more, remove it
|
||||||
|
elif self.sys_mounts.default_backup_mount == mount:
|
||||||
|
self.sys_mounts.default_backup_mount = None
|
||||||
|
|
||||||
|
self.sys_mounts.save_data()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def delete_mount(self, request: web.Request) -> None:
|
||||||
|
"""Delete an existing mount in supervisor."""
|
||||||
|
name = request.match_info.get("mount")
|
||||||
|
mount = await self.sys_mounts.remove_mount(name)
|
||||||
|
|
||||||
|
# If it was a backup mount, reload backups
|
||||||
|
if mount.usage == MountUsage.BACKUP:
|
||||||
|
self.sys_create_task(self.sys_backups.reload())
|
||||||
|
|
||||||
|
self.sys_mounts.save_data()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def reload_mount(self, request: web.Request) -> None:
|
||||||
|
"""Reload an existing mount in supervisor."""
|
||||||
|
name = request.match_info.get("mount")
|
||||||
|
await self.sys_mounts.reload_mount(name)
|
||||||
|
|
||||||
|
# If it's a backup mount, reload backups
|
||||||
|
if self.sys_mounts.get(name).usage == MountUsage.BACKUP:
|
||||||
|
self.sys_create_task(self.sys_backups.reload())
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Init file for Supervisor Multicast RESTful API."""
|
"""Init file for Supervisor Multicast RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -18,12 +20,11 @@ from ..const import (
|
|||||||
ATTR_UPDATE_AVAILABLE,
|
ATTR_UPDATE_AVAILABLE,
|
||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
ATTR_VERSION_LATEST,
|
ATTR_VERSION_LATEST,
|
||||||
CONTENT_TYPE_BINARY,
|
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..exceptions import APIError
|
from ..exceptions import APIError
|
||||||
from ..validate import version_tag
|
from ..validate import version_tag
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ class APIMulticast(CoreSysAttributes):
|
|||||||
"""Handle RESTful API for Multicast functions."""
|
"""Handle RESTful API for Multicast functions."""
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return Multicast information."""
|
"""Return Multicast information."""
|
||||||
return {
|
return {
|
||||||
ATTR_VERSION: self.sys_plugins.multicast.version,
|
ATTR_VERSION: self.sys_plugins.multicast.version,
|
||||||
@@ -43,7 +44,7 @@ class APIMulticast(CoreSysAttributes):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return resource information."""
|
"""Return resource information."""
|
||||||
stats = await self.sys_plugins.multicast.stats()
|
stats = await self.sys_plugins.multicast.stats()
|
||||||
|
|
||||||
@@ -68,11 +69,6 @@ class APIMulticast(CoreSysAttributes):
|
|||||||
raise APIError(f"Version {version} is already in use")
|
raise APIError(f"Version {version} is already in use")
|
||||||
await asyncio.shield(self.sys_plugins.multicast.update(version))
|
await asyncio.shield(self.sys_plugins.multicast.update(version))
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
|
||||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
|
||||||
"""Return Multicast Docker logs."""
|
|
||||||
return self.sys_plugins.multicast.logs()
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def restart(self, request: web.Request) -> Awaitable[None]:
|
def restart(self, request: web.Request) -> Awaitable[None]:
|
||||||
"""Restart Multicast plugin."""
|
"""Restart Multicast plugin."""
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""REST API for network."""
|
"""REST API for network."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from ipaddress import ip_address, ip_interface
|
from collections.abc import Awaitable
|
||||||
from typing import Any, Dict
|
from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import attr
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
@@ -18,6 +19,7 @@ from ..const import (
|
|||||||
ATTR_FREQUENCY,
|
ATTR_FREQUENCY,
|
||||||
ATTR_GATEWAY,
|
ATTR_GATEWAY,
|
||||||
ATTR_HOST_INTERNET,
|
ATTR_HOST_INTERNET,
|
||||||
|
ATTR_ID,
|
||||||
ATTR_INTERFACE,
|
ATTR_INTERFACE,
|
||||||
ATTR_INTERFACES,
|
ATTR_INTERFACES,
|
||||||
ATTR_IPV4,
|
ATTR_IPV4,
|
||||||
@@ -26,8 +28,10 @@ from ..const import (
|
|||||||
ATTR_METHOD,
|
ATTR_METHOD,
|
||||||
ATTR_MODE,
|
ATTR_MODE,
|
||||||
ATTR_NAMESERVERS,
|
ATTR_NAMESERVERS,
|
||||||
|
ATTR_PARENT,
|
||||||
ATTR_PRIMARY,
|
ATTR_PRIMARY,
|
||||||
ATTR_PSK,
|
ATTR_PSK,
|
||||||
|
ATTR_READY,
|
||||||
ATTR_SIGNAL,
|
ATTR_SIGNAL,
|
||||||
ATTR_SSID,
|
ATTR_SSID,
|
||||||
ATTR_SUPERVISOR_INTERNET,
|
ATTR_SUPERVISOR_INTERNET,
|
||||||
@@ -39,23 +43,33 @@ from ..const import (
|
|||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..exceptions import APIError, HostNetworkNotFound
|
from ..exceptions import APIError, HostNetworkNotFound
|
||||||
from ..host.const import AuthMethod, InterfaceType, WifiMode
|
from ..host.configuration import (
|
||||||
from ..host.network import (
|
|
||||||
AccessPoint,
|
AccessPoint,
|
||||||
Interface,
|
Interface,
|
||||||
InterfaceMethod,
|
InterfaceMethod,
|
||||||
IpConfig,
|
IpConfig,
|
||||||
|
IpSetting,
|
||||||
VlanConfig,
|
VlanConfig,
|
||||||
WifiConfig,
|
WifiConfig,
|
||||||
)
|
)
|
||||||
|
from ..host.const import AuthMethod, InterfaceType, WifiMode
|
||||||
from .utils import api_process, api_validate
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
_SCHEMA_IP_CONFIG = vol.Schema(
|
_SCHEMA_IPV4_CONFIG = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_ADDRESS): [vol.Coerce(ip_interface)],
|
vol.Optional(ATTR_ADDRESS): [vol.Coerce(IPv4Interface)],
|
||||||
vol.Optional(ATTR_METHOD): vol.Coerce(InterfaceMethod),
|
vol.Optional(ATTR_METHOD): vol.Coerce(InterfaceMethod),
|
||||||
vol.Optional(ATTR_GATEWAY): vol.Coerce(ip_address),
|
vol.Optional(ATTR_GATEWAY): vol.Coerce(IPv4Address),
|
||||||
vol.Optional(ATTR_NAMESERVERS): [vol.Coerce(ip_address)],
|
vol.Optional(ATTR_NAMESERVERS): [vol.Coerce(IPv4Address)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_SCHEMA_IPV6_CONFIG = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_ADDRESS): [vol.Coerce(IPv6Interface)],
|
||||||
|
vol.Optional(ATTR_METHOD): vol.Coerce(InterfaceMethod),
|
||||||
|
vol.Optional(ATTR_GATEWAY): vol.Coerce(IPv6Address),
|
||||||
|
vol.Optional(ATTR_NAMESERVERS): [vol.Coerce(IPv6Address)],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,25 +86,26 @@ _SCHEMA_WIFI_CONFIG = vol.Schema(
|
|||||||
# pylint: disable=no-value-for-parameter
|
# pylint: disable=no-value-for-parameter
|
||||||
SCHEMA_UPDATE = vol.Schema(
|
SCHEMA_UPDATE = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_IPV4): _SCHEMA_IP_CONFIG,
|
vol.Optional(ATTR_IPV4): _SCHEMA_IPV4_CONFIG,
|
||||||
vol.Optional(ATTR_IPV6): _SCHEMA_IP_CONFIG,
|
vol.Optional(ATTR_IPV6): _SCHEMA_IPV6_CONFIG,
|
||||||
vol.Optional(ATTR_WIFI): _SCHEMA_WIFI_CONFIG,
|
vol.Optional(ATTR_WIFI): _SCHEMA_WIFI_CONFIG,
|
||||||
vol.Optional(ATTR_ENABLED): vol.Boolean(),
|
vol.Optional(ATTR_ENABLED): vol.Boolean(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def ipconfig_struct(config: IpConfig) -> dict:
|
def ipconfig_struct(config: IpConfig, setting: IpSetting) -> dict[str, Any]:
|
||||||
"""Return a dict with information about ip configuration."""
|
"""Return a dict with information about ip configuration."""
|
||||||
return {
|
return {
|
||||||
ATTR_METHOD: config.method,
|
ATTR_METHOD: setting.method,
|
||||||
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
|
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
|
||||||
ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
|
ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
|
||||||
ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
|
ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
|
||||||
|
ATTR_READY: config.ready,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def wifi_struct(config: WifiConfig) -> dict:
|
def wifi_struct(config: WifiConfig) -> dict[str, Any]:
|
||||||
"""Return a dict with information about wifi configuration."""
|
"""Return a dict with information about wifi configuration."""
|
||||||
return {
|
return {
|
||||||
ATTR_MODE: config.mode,
|
ATTR_MODE: config.mode,
|
||||||
@@ -100,7 +115,15 @@ def wifi_struct(config: WifiConfig) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def interface_struct(interface: Interface) -> dict:
|
def vlan_struct(config: VlanConfig) -> dict[str, Any]:
|
||||||
|
"""Return a dict with information about VLAN configuration."""
|
||||||
|
return {
|
||||||
|
ATTR_ID: config.id,
|
||||||
|
ATTR_PARENT: config.interface,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def interface_struct(interface: Interface) -> dict[str, Any]:
|
||||||
"""Return a dict with information of a interface to be used in th API."""
|
"""Return a dict with information of a interface to be used in th API."""
|
||||||
return {
|
return {
|
||||||
ATTR_INTERFACE: interface.name,
|
ATTR_INTERFACE: interface.name,
|
||||||
@@ -108,14 +131,15 @@ def interface_struct(interface: Interface) -> dict:
|
|||||||
ATTR_ENABLED: interface.enabled,
|
ATTR_ENABLED: interface.enabled,
|
||||||
ATTR_CONNECTED: interface.connected,
|
ATTR_CONNECTED: interface.connected,
|
||||||
ATTR_PRIMARY: interface.primary,
|
ATTR_PRIMARY: interface.primary,
|
||||||
ATTR_IPV4: ipconfig_struct(interface.ipv4) if interface.ipv4 else None,
|
ATTR_MAC: interface.mac,
|
||||||
ATTR_IPV6: ipconfig_struct(interface.ipv6) if interface.ipv6 else None,
|
ATTR_IPV4: ipconfig_struct(interface.ipv4, interface.ipv4setting),
|
||||||
|
ATTR_IPV6: ipconfig_struct(interface.ipv6, interface.ipv6setting),
|
||||||
ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None,
|
ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None,
|
||||||
ATTR_VLAN: wifi_struct(interface.vlan) if interface.vlan else None,
|
ATTR_VLAN: vlan_struct(interface.vlan) if interface.vlan else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def accesspoint_struct(accesspoint: AccessPoint) -> dict:
|
def accesspoint_struct(accesspoint: AccessPoint) -> dict[str, Any]:
|
||||||
"""Return a dict for AccessPoint."""
|
"""Return a dict for AccessPoint."""
|
||||||
return {
|
return {
|
||||||
ATTR_MODE: accesspoint.mode,
|
ATTR_MODE: accesspoint.mode,
|
||||||
@@ -131,9 +155,7 @@ class APINetwork(CoreSysAttributes):
|
|||||||
|
|
||||||
def _get_interface(self, name: str) -> Interface:
|
def _get_interface(self, name: str) -> Interface:
|
||||||
"""Get Interface by name or default."""
|
"""Get Interface by name or default."""
|
||||||
name = name.lower()
|
if name.lower() == "default":
|
||||||
|
|
||||||
if name == "default":
|
|
||||||
for interface in self.sys_host.network.interfaces:
|
for interface in self.sys_host.network.interfaces:
|
||||||
if not interface.primary:
|
if not interface.primary:
|
||||||
continue
|
continue
|
||||||
@@ -145,10 +167,10 @@ class APINetwork(CoreSysAttributes):
|
|||||||
except HostNetworkNotFound:
|
except HostNetworkNotFound:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
raise APIError(f"Interface {name} does not exsist") from None
|
raise APIError(f"Interface {name} does not exist") from None
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return network information."""
|
"""Return network information."""
|
||||||
return {
|
return {
|
||||||
ATTR_INTERFACES: [
|
ATTR_INTERFACES: [
|
||||||
@@ -166,7 +188,7 @@ class APINetwork(CoreSysAttributes):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def interface_info(self, request: web.Request) -> Dict[str, Any]:
|
async def interface_info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return network information for a interface."""
|
"""Return network information for a interface."""
|
||||||
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
||||||
|
|
||||||
@@ -185,22 +207,26 @@ class APINetwork(CoreSysAttributes):
|
|||||||
# Apply config
|
# Apply config
|
||||||
for key, config in body.items():
|
for key, config in body.items():
|
||||||
if key == ATTR_IPV4:
|
if key == ATTR_IPV4:
|
||||||
interface.ipv4 = attr.evolve(
|
interface.ipv4setting = IpSetting(
|
||||||
interface.ipv4 or IpConfig(InterfaceMethod.STATIC, [], None, []),
|
config.get(ATTR_METHOD, InterfaceMethod.STATIC),
|
||||||
**config,
|
config.get(ATTR_ADDRESS, []),
|
||||||
|
config.get(ATTR_GATEWAY),
|
||||||
|
config.get(ATTR_NAMESERVERS, []),
|
||||||
)
|
)
|
||||||
elif key == ATTR_IPV6:
|
elif key == ATTR_IPV6:
|
||||||
interface.ipv6 = attr.evolve(
|
interface.ipv6setting = IpSetting(
|
||||||
interface.ipv6 or IpConfig(InterfaceMethod.STATIC, [], None, []),
|
config.get(ATTR_METHOD, InterfaceMethod.STATIC),
|
||||||
**config,
|
config.get(ATTR_ADDRESS, []),
|
||||||
|
config.get(ATTR_GATEWAY),
|
||||||
|
config.get(ATTR_NAMESERVERS, []),
|
||||||
)
|
)
|
||||||
elif key == ATTR_WIFI:
|
elif key == ATTR_WIFI:
|
||||||
interface.wifi = attr.evolve(
|
interface.wifi = WifiConfig(
|
||||||
interface.wifi
|
config.get(ATTR_MODE, WifiMode.INFRASTRUCTURE),
|
||||||
or WifiConfig(
|
config.get(ATTR_SSID, ""),
|
||||||
WifiMode.INFRASTRUCTURE, "", AuthMethod.OPEN, None, None
|
config.get(ATTR_AUTH, AuthMethod.OPEN),
|
||||||
),
|
config.get(ATTR_PSK, None),
|
||||||
**config,
|
None,
|
||||||
)
|
)
|
||||||
elif key == ATTR_ENABLED:
|
elif key == ATTR_ENABLED:
|
||||||
interface.enabled = config
|
interface.enabled = config
|
||||||
@@ -208,7 +234,14 @@ class APINetwork(CoreSysAttributes):
|
|||||||
await asyncio.shield(self.sys_host.network.apply_changes(interface))
|
await asyncio.shield(self.sys_host.network.apply_changes(interface))
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def scan_accesspoints(self, request: web.Request) -> Dict[str, Any]:
|
def reload(self, request: web.Request) -> Awaitable[None]:
|
||||||
|
"""Reload network data."""
|
||||||
|
return asyncio.shield(
|
||||||
|
self.sys_host.network.update(force_connectivity_check=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def scan_accesspoints(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Scan and return a list of available networks."""
|
"""Scan and return a list of available networks."""
|
||||||
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
||||||
|
|
||||||
@@ -235,18 +268,18 @@ class APINetwork(CoreSysAttributes):
|
|||||||
|
|
||||||
vlan_config = VlanConfig(vlan, interface.name)
|
vlan_config = VlanConfig(vlan, interface.name)
|
||||||
|
|
||||||
ipv4_config = None
|
ipv4_setting = None
|
||||||
if ATTR_IPV4 in body:
|
if ATTR_IPV4 in body:
|
||||||
ipv4_config = IpConfig(
|
ipv4_setting = IpSetting(
|
||||||
body[ATTR_IPV4].get(ATTR_METHOD, InterfaceMethod.AUTO),
|
body[ATTR_IPV4].get(ATTR_METHOD, InterfaceMethod.AUTO),
|
||||||
body[ATTR_IPV4].get(ATTR_ADDRESS, []),
|
body[ATTR_IPV4].get(ATTR_ADDRESS, []),
|
||||||
body[ATTR_IPV4].get(ATTR_GATEWAY, None),
|
body[ATTR_IPV4].get(ATTR_GATEWAY, None),
|
||||||
body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
|
body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
|
||||||
)
|
)
|
||||||
|
|
||||||
ipv6_config = None
|
ipv6_setting = None
|
||||||
if ATTR_IPV6 in body:
|
if ATTR_IPV6 in body:
|
||||||
ipv6_config = IpConfig(
|
ipv6_setting = IpSetting(
|
||||||
body[ATTR_IPV6].get(ATTR_METHOD, InterfaceMethod.AUTO),
|
body[ATTR_IPV6].get(ATTR_METHOD, InterfaceMethod.AUTO),
|
||||||
body[ATTR_IPV6].get(ATTR_ADDRESS, []),
|
body[ATTR_IPV6].get(ATTR_ADDRESS, []),
|
||||||
body[ATTR_IPV6].get(ATTR_GATEWAY, None),
|
body[ATTR_IPV6].get(ATTR_GATEWAY, None),
|
||||||
@@ -254,13 +287,17 @@ class APINetwork(CoreSysAttributes):
|
|||||||
)
|
)
|
||||||
|
|
||||||
vlan_interface = Interface(
|
vlan_interface = Interface(
|
||||||
|
"",
|
||||||
|
"",
|
||||||
"",
|
"",
|
||||||
True,
|
True,
|
||||||
True,
|
True,
|
||||||
False,
|
False,
|
||||||
InterfaceType.VLAN,
|
InterfaceType.VLAN,
|
||||||
ipv4_config,
|
None,
|
||||||
ipv6_config,
|
ipv4_setting,
|
||||||
|
None,
|
||||||
|
ipv6_setting,
|
||||||
None,
|
None,
|
||||||
vlan_config,
|
vlan_config,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Init file for Supervisor Observer RESTful API."""
|
"""Init file for Supervisor Observer RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -33,7 +34,7 @@ class APIObserver(CoreSysAttributes):
|
|||||||
"""Handle RESTful API for Observer functions."""
|
"""Handle RESTful API for Observer functions."""
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return HA Observer information."""
|
"""Return HA Observer information."""
|
||||||
return {
|
return {
|
||||||
ATTR_HOST: str(self.sys_docker.network.observer),
|
ATTR_HOST: str(self.sys_docker.network.observer),
|
||||||
@@ -43,7 +44,7 @@ class APIObserver(CoreSysAttributes):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
async def stats(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return resource information."""
|
"""Return resource information."""
|
||||||
stats = await self.sys_plugins.observer.stats()
|
stats = await self.sys_plugins.observer.stats()
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,214 @@
|
|||||||
"""Init file for Supervisor HassOS RESTful API."""
|
"""Init file for Supervisor HassOS RESTful API."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Dict
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
|
ATTR_ACTIVITY_LED,
|
||||||
ATTR_BOARD,
|
ATTR_BOARD,
|
||||||
ATTR_BOOT,
|
ATTR_BOOT,
|
||||||
|
ATTR_DEVICES,
|
||||||
|
ATTR_DISK_LED,
|
||||||
|
ATTR_HEARTBEAT_LED,
|
||||||
|
ATTR_ID,
|
||||||
|
ATTR_NAME,
|
||||||
|
ATTR_POWER_LED,
|
||||||
|
ATTR_SERIAL,
|
||||||
|
ATTR_SIZE,
|
||||||
|
ATTR_STATE,
|
||||||
ATTR_UPDATE_AVAILABLE,
|
ATTR_UPDATE_AVAILABLE,
|
||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
ATTR_VERSION_LATEST,
|
ATTR_VERSION_LATEST,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
|
from ..exceptions import BoardInvalidError
|
||||||
|
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||||
from ..validate import version_tag
|
from ..validate import version_tag
|
||||||
|
from .const import (
|
||||||
|
ATTR_BOOT_SLOT,
|
||||||
|
ATTR_BOOT_SLOTS,
|
||||||
|
ATTR_DATA_DISK,
|
||||||
|
ATTR_DEV_PATH,
|
||||||
|
ATTR_DEVICE,
|
||||||
|
ATTR_DISKS,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_STATUS,
|
||||||
|
ATTR_SYSTEM_HEALTH_LED,
|
||||||
|
ATTR_VENDOR,
|
||||||
|
BootSlot,
|
||||||
|
)
|
||||||
from .utils import api_process, api_validate
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
||||||
|
SCHEMA_SET_BOOT_SLOT = vol.Schema({vol.Required(ATTR_BOOT_SLOT): vol.Coerce(BootSlot)})
|
||||||
|
SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): str})
|
||||||
|
|
||||||
|
SCHEMA_YELLOW_OPTIONS = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_DISK_LED): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_HEARTBEAT_LED): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_POWER_LED): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SCHEMA_GREEN_OPTIONS = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_ACTIVITY_LED): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_POWER_LED): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_SYSTEM_HEALTH_LED): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# pylint: enable=no-value-for-parameter
|
||||||
|
|
||||||
|
|
||||||
class APIOS(CoreSysAttributes):
|
class APIOS(CoreSysAttributes):
|
||||||
"""Handle RESTful API for OS functions."""
|
"""Handle RESTful API for OS functions."""
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
"""Return OS information."""
|
"""Return OS information."""
|
||||||
return {
|
return {
|
||||||
ATTR_VERSION: self.sys_hassos.version,
|
ATTR_VERSION: self.sys_os.version,
|
||||||
ATTR_VERSION_LATEST: self.sys_hassos.latest_version,
|
ATTR_VERSION_LATEST: self.sys_os.latest_version,
|
||||||
ATTR_UPDATE_AVAILABLE: self.sys_hassos.need_update,
|
ATTR_UPDATE_AVAILABLE: self.sys_os.need_update,
|
||||||
ATTR_BOARD: self.sys_hassos.board,
|
ATTR_BOARD: self.sys_os.board,
|
||||||
ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
|
ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
|
||||||
|
ATTR_DATA_DISK: self.sys_os.datadisk.disk_used_id,
|
||||||
|
ATTR_BOOT_SLOTS: {
|
||||||
|
slot.bootname: {
|
||||||
|
ATTR_STATE: slot.state,
|
||||||
|
ATTR_STATUS: slot.boot_status,
|
||||||
|
ATTR_VERSION: slot.bundle_version,
|
||||||
|
}
|
||||||
|
for slot in self.sys_os.slots
|
||||||
|
if slot.bootname
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def update(self, request: web.Request) -> None:
|
async def update(self, request: web.Request) -> None:
|
||||||
"""Update OS."""
|
"""Update OS."""
|
||||||
body = await api_validate(SCHEMA_VERSION, request)
|
body = await api_validate(SCHEMA_VERSION, request)
|
||||||
version = body.get(ATTR_VERSION, self.sys_hassos.latest_version)
|
version = body.get(ATTR_VERSION, self.sys_os.latest_version)
|
||||||
|
|
||||||
await asyncio.shield(self.sys_hassos.update(version))
|
await asyncio.shield(self.sys_os.update(version))
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def config_sync(self, request: web.Request) -> Awaitable[None]:
|
def config_sync(self, request: web.Request) -> Awaitable[None]:
|
||||||
"""Trigger config reload on OS."""
|
"""Trigger config reload on OS."""
|
||||||
return asyncio.shield(self.sys_hassos.config_sync())
|
return asyncio.shield(self.sys_os.config_sync())
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def migrate_data(self, request: web.Request) -> None:
|
||||||
|
"""Trigger data disk migration on Host."""
|
||||||
|
body = await api_validate(SCHEMA_DISK, request)
|
||||||
|
|
||||||
|
await asyncio.shield(self.sys_os.datadisk.migrate_disk(body[ATTR_DEVICE]))
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
def wipe_data(self, request: web.Request) -> Awaitable[None]:
|
||||||
|
"""Trigger data disk wipe on Host."""
|
||||||
|
return asyncio.shield(self.sys_os.datadisk.wipe_disk())
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def set_boot_slot(self, request: web.Request) -> None:
|
||||||
|
"""Change the active boot slot and reboot into it."""
|
||||||
|
body = await api_validate(SCHEMA_SET_BOOT_SLOT, request)
|
||||||
|
await asyncio.shield(self.sys_os.set_boot_slot(body[ATTR_BOOT_SLOT]))
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def list_data(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Return possible data targets."""
|
||||||
|
return {
|
||||||
|
ATTR_DEVICES: [disk.id for disk in self.sys_os.datadisk.available_disks],
|
||||||
|
ATTR_DISKS: [
|
||||||
|
{
|
||||||
|
ATTR_NAME: disk.name,
|
||||||
|
ATTR_VENDOR: disk.vendor,
|
||||||
|
ATTR_MODEL: disk.model,
|
||||||
|
ATTR_SERIAL: disk.serial,
|
||||||
|
ATTR_SIZE: disk.size,
|
||||||
|
ATTR_ID: disk.id,
|
||||||
|
ATTR_DEV_PATH: disk.device_path.as_posix(),
|
||||||
|
}
|
||||||
|
for disk in self.sys_os.datadisk.available_disks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def boards_green_info(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Get green board settings."""
|
||||||
|
return {
|
||||||
|
ATTR_ACTIVITY_LED: self.sys_dbus.agent.board.green.activity_led,
|
||||||
|
ATTR_POWER_LED: self.sys_dbus.agent.board.green.power_led,
|
||||||
|
ATTR_SYSTEM_HEALTH_LED: self.sys_dbus.agent.board.green.user_led,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def boards_green_options(self, request: web.Request) -> None:
|
||||||
|
"""Update green board settings."""
|
||||||
|
body = await api_validate(SCHEMA_GREEN_OPTIONS, request)
|
||||||
|
|
||||||
|
if ATTR_ACTIVITY_LED in body:
|
||||||
|
await self.sys_dbus.agent.board.green.set_activity_led(
|
||||||
|
body[ATTR_ACTIVITY_LED]
|
||||||
|
)
|
||||||
|
|
||||||
|
if ATTR_POWER_LED in body:
|
||||||
|
await self.sys_dbus.agent.board.green.set_power_led(body[ATTR_POWER_LED])
|
||||||
|
|
||||||
|
if ATTR_SYSTEM_HEALTH_LED in body:
|
||||||
|
await self.sys_dbus.agent.board.green.set_user_led(
|
||||||
|
body[ATTR_SYSTEM_HEALTH_LED]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sys_dbus.agent.board.green.save_data()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def boards_yellow_info(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Get yellow board settings."""
|
||||||
|
return {
|
||||||
|
ATTR_DISK_LED: self.sys_dbus.agent.board.yellow.disk_led,
|
||||||
|
ATTR_HEARTBEAT_LED: self.sys_dbus.agent.board.yellow.heartbeat_led,
|
||||||
|
ATTR_POWER_LED: self.sys_dbus.agent.board.yellow.power_led,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def boards_yellow_options(self, request: web.Request) -> None:
|
||||||
|
"""Update yellow board settings."""
|
||||||
|
body = await api_validate(SCHEMA_YELLOW_OPTIONS, request)
|
||||||
|
|
||||||
|
if ATTR_DISK_LED in body:
|
||||||
|
await self.sys_dbus.agent.board.yellow.set_disk_led(body[ATTR_DISK_LED])
|
||||||
|
|
||||||
|
if ATTR_HEARTBEAT_LED in body:
|
||||||
|
await self.sys_dbus.agent.board.yellow.set_heartbeat_led(
|
||||||
|
body[ATTR_HEARTBEAT_LED]
|
||||||
|
)
|
||||||
|
|
||||||
|
if ATTR_POWER_LED in body:
|
||||||
|
await self.sys_dbus.agent.board.yellow.set_power_led(body[ATTR_POWER_LED])
|
||||||
|
|
||||||
|
self.sys_dbus.agent.board.yellow.save_data()
|
||||||
|
self.sys_resolution.create_issue(
|
||||||
|
IssueType.REBOOT_REQUIRED,
|
||||||
|
ContextType.SYSTEM,
|
||||||
|
suggestions=[SuggestionType.EXECUTE_REBOOT],
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def boards_other_info(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Empty success return if board is in use, error otherwise."""
|
||||||
|
if request.match_info["board"] != self.sys_os.board:
|
||||||
|
raise BoardInvalidError(
|
||||||
|
f"{request.match_info['board']} board is not in use", _LOGGER.error
|
||||||
|
)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|||||||
@@ -1,9 +1 @@
|
|||||||
|
!function(){function n(n){var t=document.createElement("script");t.src=n,document.body.appendChild(t)}if(/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent))n("/api/hassio/app/frontend_es5/entrypoint-5yRSddAJzJ4.js");else try{new Function("import('/api/hassio/app/frontend_latest/entrypoint-qzB1D0O4L9U.js')")()}catch(t){n("/api/hassio/app/frontend_es5/entrypoint-5yRSddAJzJ4.js")}}()
|
||||||
try {
|
|
||||||
new Function("import('/api/hassio/app/frontend_latest/entrypoint.df099659.js')")();
|
|
||||||
} catch (err) {
|
|
||||||
var el = document.createElement('script');
|
|
||||||
el.src = '/api/hassio/app/frontend_es5/entrypoint.d303236e.js';
|
|
||||||
document.body.appendChild(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
Binary file not shown.
2
supervisor/api/panel/frontend_es5/1036-G1AUvfK_ULU.js
Normal file
2
supervisor/api/panel/frontend_es5/1036-G1AUvfK_ULU.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1036-G1AUvfK_ULU.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1036-G1AUvfK_ULU.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
2
supervisor/api/panel/frontend_es5/1047-g7fFLS9eP4I.js
Normal file
2
supervisor/api/panel/frontend_es5/1047-g7fFLS9eP4I.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";(self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[]).push([[1047],{32594:function(e,t,r){r.d(t,{U:function(){return n}});var n=function(e){return e.stopPropagation()}},75054:function(e,t,r){r.r(t),r.d(t,{HaTimeDuration:function(){return f}});var n,a=r(88962),i=r(33368),o=r(71650),d=r(82390),u=r(69205),l=r(70906),s=r(91808),c=r(68144),v=r(79932),f=(r(47289),(0,s.Z)([(0,v.Mo)("ha-selector-duration")],(function(e,t){var r=function(t){(0,u.Z)(n,t);var r=(0,l.Z)(n);function n(){var t;(0,o.Z)(this,n);for(var a=arguments.length,i=new Array(a),u=0;u<a;u++)i[u]=arguments[u];return t=r.call.apply(r,[this].concat(i)),e((0,d.Z)(t)),t}return(0,i.Z)(n)}(t);return{F:r,d:[{kind:"field",decorators:[(0,v.Cb)({attribute:!1})],key:"hass",value:void 0},{kind:"field",decorators:[(0,v.Cb)({attribute:!1})],key:"selector",value:void 0},{kind:"field",decorators:[(0,v.Cb)({attribute:!1})],key:"value",value:void 0},{kind:"field",decorators:[(0,v.Cb)()],key:"label",value:void 0},{kind:"field",decorators:[(0,v.Cb)()],key:"helper",value:void 0},{kind:"field",decorators:[(0,v.Cb)({type:Boolean})],key:"disabled",value:function(){return!1}},{kind:"field",decorators:[(0,v.Cb)({type:Boolean})],key:"required",value:function(){return!0}},{kind:"method",key:"render",value:function(){var e;return(0,c.dy)(n||(n=(0,a.Z)([' <ha-duration-input .label="','" .helper="','" .data="','" .disabled="','" .required="','" ?enableDay="','"></ha-duration-input> '])),this.label,this.helper,this.value,this.disabled,this.required,null===(e=this.selector.duration)||void 0===e?void 0:e.enable_day)}}]}}),c.oi))}}]);
|
||||||
|
//# sourceMappingURL=1047-g7fFLS9eP4I.js.map
|
||||||
BIN
supervisor/api/panel/frontend_es5/1047-g7fFLS9eP4I.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1047-g7fFLS9eP4I.js.gz
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"1047-g7fFLS9eP4I.js","mappings":"yKAAO,IAAMA,EAAkB,SAACC,GAAE,OAAKA,EAAGD,iBAAiB,C,qLCQ9CE,G,UAAcC,EAAAA,EAAAA,GAAA,EAD1BC,EAAAA,EAAAA,IAAc,0BAAuB,SAAAC,EAAAC,GAAA,IACzBJ,EAAc,SAAAK,IAAAC,EAAAA,EAAAA,GAAAN,EAAAK,GAAA,IAAAE,GAAAC,EAAAA,EAAAA,GAAAR,GAAA,SAAAA,IAAA,IAAAS,GAAAC,EAAAA,EAAAA,GAAA,KAAAV,GAAA,QAAAW,EAAAC,UAAAC,OAAAC,EAAA,IAAAC,MAAAJ,GAAAK,EAAA,EAAAA,EAAAL,EAAAK,IAAAF,EAAAE,GAAAJ,UAAAI,GAAA,OAAAP,EAAAF,EAAAU,KAAAC,MAAAX,EAAA,OAAAY,OAAAL,IAAAX,GAAAiB,EAAAA,EAAAA,GAAAX,IAAAA,CAAA,QAAAY,EAAAA,EAAAA,GAAArB,EAAA,EAAAI,GAAA,OAAAkB,EAAdtB,EAAcuB,EAAA,EAAAC,KAAA,QAAAC,WAAA,EACxBC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,OAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,WAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,SAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,MAAA,kBAAmB,CAAK,IAAAL,KAAA,QAAAC,WAAA,EAEnDC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,MAAA,kBAAmB,CAAI,IAAAL,KAAA,SAAAI,IAAA,SAAAC,MAEnD,WAAmB,IAAAG,EACjB,OAAOC,EAAAA,EAAAA,IAAIC,IAAAA,GAAAC,EAAAA,EAAAA,GAAA,wIAEEC,KAAKC,MACJD,KAAKE,OACPF,KAAKP,MACDO,KAAKG,SACLH,KAAKI,SACkB,QADVR,EACZI,KAAKK,SAASC,gBAAQ,IAAAV,OAAA,EAAtBA,EAAwBW,WAG3C,IAAC,GA1BiCC,EAAAA,I","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20230703.0/src/common/dom/stop_propagation.ts","https://raw.githubusercontent.com/home-assistant/frontend/20230703.0/src/components/ha-selector/ha-selector-duration.ts"],"names":["stopPropagation","ev","HaTimeDuration","_decorate","customElement","_initialize","_LitElement","_LitElement2","_inherits","_super","_createSuper","_this","_classCallCheck","_len","arguments","length","args","Array","_key","call","apply","concat","_assertThisInitialized","_createClass","F","d","kind","decorators","property","attribute","key","value","type","Boolean","_this$selector$durati","html","_templateObject","_taggedTemplateLiteral","this","label","helper","disabled","required","selector","duration","enable_day","LitElement"],"sourceRoot":""}
|
||||||
2
supervisor/api/panel/frontend_es5/1074-djfpWNdWsA8.js
Normal file
2
supervisor/api/panel/frontend_es5/1074-djfpWNdWsA8.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1074-djfpWNdWsA8.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1074-djfpWNdWsA8.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
2
supervisor/api/panel/frontend_es5/1116-xNyDWQHsExg.js
Normal file
2
supervisor/api/panel/frontend_es5/1116-xNyDWQHsExg.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1116-xNyDWQHsExg.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1116-xNyDWQHsExg.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
2
supervisor/api/panel/frontend_es5/1193--qnpEuA6qSY.js
Normal file
2
supervisor/api/panel/frontend_es5/1193--qnpEuA6qSY.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1193--qnpEuA6qSY.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1193--qnpEuA6qSY.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
2
supervisor/api/panel/frontend_es5/1265-yCkoy0FMl6o.js
Normal file
2
supervisor/api/panel/frontend_es5/1265-yCkoy0FMl6o.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1265-yCkoy0FMl6o.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1265-yCkoy0FMl6o.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
2
supervisor/api/panel/frontend_es5/1281-On4tZThCfZs.js
Normal file
2
supervisor/api/panel/frontend_es5/1281-On4tZThCfZs.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1281-On4tZThCfZs.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1281-On4tZThCfZs.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
2
supervisor/api/panel/frontend_es5/1402-6WKUruvoXtM.js
Normal file
2
supervisor/api/panel/frontend_es5/1402-6WKUruvoXtM.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
!function(){"use strict";var n,t,e={14595:function(n,t,e){e(58556);var r,i,o=e(93217),u=e(422),a=e(62173),s=function(n,t,e){if("input"===n){if("type"===t&&"checkbox"===e||"checked"===t||"disabled"===t)return;return""}},c={renderMarkdown:function(n,t){var e,o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return r||(r=Object.assign(Object.assign({},(0,a.getDefaultWhiteList)()),{},{input:["type","disabled","checked"],"ha-icon":["icon"],"ha-svg-icon":["path"],"ha-alert":["alert-type","title"]})),o.allowSvg?(i||(i=Object.assign(Object.assign({},r),{},{svg:["xmlns","height","width"],path:["transform","stroke","d"],img:["src"]})),e=i):e=r,(0,a.filterXSS)((0,u.TU)(n,t),{whiteList:e,onTagAttr:s})}};(0,o.Jj)(c)}},r={};function i(n){var t=r[n];if(void 0!==t)return t.exports;var o=r[n]={exports:{}};return e[n](o,o.exports,i),o.exports}i.m=e,i.x=function(){var n=i.O(void 0,[9191,215],(function(){return i(14595)}));return n=i.O(n)},n=[],i.O=function(t,e,r,o){if(!e){var u=1/0;for(f=0;f<n.length;f++){e=n[f][0],r=n[f][1],o=n[f][2];for(var a=!0,s=0;s<e.length;s++)(!1&o||u>=o)&&Object.keys(i.O).every((function(n){return i.O[n](e[s])}))?e.splice(s--,1):(a=!1,o<u&&(u=o));if(a){n.splice(f--,1);var c=r();void 0!==c&&(t=c)}}return t}o=o||0;for(var f=n.length;f>0&&n[f-1][2]>o;f--)n[f]=n[f-1];n[f]=[e,r,o]},i.n=function(n){var t=n&&n.__esModule?function(){return n.default}:function(){return n};return i.d(t,{a:t}),t},i.d=function(n,t){for(var e in t)i.o(t,e)&&!i.o(n,e)&&Object.defineProperty(n,e,{enumerable:!0,get:t[e]})},i.f={},i.e=function(n){return Promise.all(Object.keys(i.f).reduce((function(t,e){return i.f[e](n,t),t}),[]))},i.u=function(n){return n+"-"+{215:"FPZmDYZTPdk",9191:"37260H-osZ4"}[n]+".js"},i.o=function(n,t){return Object.prototype.hasOwnProperty.call(n,t)},i.p="/api/hassio/app/frontend_es5/",function(){var n={1402:1};i.f.i=function(t,e){n[t]||importScripts(i.p+i.u(t))};var t=self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[],e=t.push.bind(t);t.push=function(t){var r=t[0],o=t[1],u=t[2];for(var a in o)i.o(o,a)&&(i.m[a]=o[a]);for(u&&u(i);r.length;)n[r.pop()]=1;e(t)}}(),t=i.x,i.x=function(){return Promise.all([i.e(9191),i.e(215)]).then(t)};i.x()}();
|
||||||
|
//# sourceMappingURL=1402-6WKUruvoXtM.js.map
|
||||||
BIN
supervisor/api/panel/frontend_es5/1402-6WKUruvoXtM.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1402-6WKUruvoXtM.js.gz
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"1402-6WKUruvoXtM.js","mappings":"6BAAIA,ECAAC,E,sCCMAC,EACAC,E,+BAMEC,EAAY,SAChBC,EACAC,EACAC,GAEA,GAAY,UAARF,EAAiB,CACnB,GACY,SAATC,GAA6B,aAAVC,GACX,YAATD,GACS,aAATA,EAEA,OAEF,MAAO,EACT,CAEF,EA0CME,EAAM,CACVC,eAzCqB,SACrBC,EACAC,GAKW,IAWPC,EAfJC,EAGCC,UAAAC,OAAA,QAAAC,IAAAF,UAAA,GAAAA,UAAA,GAAG,CAAC,EA4BL,OA1BKZ,IACHA,EAAee,OAAAC,OAAAD,OAAAC,OAAA,IACVC,EAAAA,EAAAA,wBAAqB,IACxBC,MAAO,CAAC,OAAQ,WAAY,WAC5B,UAAW,CAAC,QACZ,cAAe,CAAC,QAChB,WAAY,CAAC,aAAc,YAM3BP,EAAYQ,UACTlB,IACHA,EAAYc,OAAAC,OAAAD,OAAAC,OAAA,GACPhB,GAAe,IAClBoB,IAAK,CAAC,QAAS,SAAU,SACzBC,KAAM,CAAC,YAAa,SAAU,KAC9BC,IAAK,CAAC,UAGVZ,EAAYT,GAEZS,EAAYV,GAGPuB,EAAAA,EAAAA,YAAUC,EAAAA,EAAAA,IAAOhB,EAASC,GAAgB,CAC/CC,UAAAA,EACAR,UAAAA,GAEJ,IAQAuB,EAAAA,EAAAA,IAAOnB,E,GC5EHoB,EAA2B,CAAC,EAGhC,SAASC,EAAoBC,GAE5B,IAAIC,EAAeH,EAAyBE,GAC5C,QAAqBd,IAAjBe,EACH,OAAOA,EAAaC,QAGrB,IAAIC,EAASL,EAAyBE,GAAY,CAGjDE,QAAS,CAAC,GAOX,OAHAE,EAAoBJ,GAAUG,EAAQA,EAAOD,QAASH,GAG/CI,EAAOD,OACf,CAGAH,EAAoBM,EAAID,EAGxBL,EAAoBO,EAAI,WAGvB,IAAIC,EAAsBR,EAAoBS,OAAEtB,EAAW,CAAC,KAAK,MAAM,WAAa,OAAOa,EAAoB,MAAQ,IAEvH,OADAQ,EAAsBR,EAAoBS,EAAED,EAE7C,EHlCIrC,EAAW,GACf6B,EAAoBS,EAAI,SAASC,EAAQC,EAAUC,EAAIC,GACtD,IAAGF,EAAH,CAMA,IAAIG,EAAeC,IACnB,IAASC,EAAI,EAAGA,EAAI7C,EAASe,OAAQ8B,IAAK,CACrCL,EAAWxC,EAAS6C,GAAG,GACvBJ,EAAKzC,EAAS6C,GAAG,GACjBH,EAAW1C,EAAS6C,GAAG,GAE3B,IAJA,IAGIC,GAAY,EACPC,EAAI,EAAGA,EAAIP,EAASzB,OAAQgC,MACpB,EAAXL,GAAsBC,GAAgBD,IAAazB,OAAO+B,KAAKnB,EAAoBS,GAAGW,OAAM,SAASC,GAAO,OAAOrB,EAAoBS,EAAEY,GAAKV,EAASO,GAAK,IAChKP,EAASW,OAAOJ,IAAK,IAErBD,GAAY,EACTJ,EAAWC,IAAcA,EAAeD,IAG7C,GAAGI,EAAW,CACb9C,EAASmD,OAAON,IAAK,GACrB,IAAIO,EAAIX,SACEzB,IAANoC,IAAiBb,EAASa,EAC/B,CACD,CACA,OAAOb,CArBP,CAJCG,EAAWA,GAAY,EACvB,IAAI,IAAIG,EAAI7C,EAASe,OAAQ8B,EAAI,GAAK7C,EAAS6C,EAAI,GAAG,GAAKH,EAAUG,IAAK7C,EAAS6C,GAAK7C,EAAS6C,EAAI,GACrG7C,EAAS6C,GAAK,CAACL,EAAUC,EAAIC,EAwB/B,EI5BAb,EAAoBwB,EAAI,SAASpB,GAChC,IAAIqB,EAASrB,GAAUA,EAAOsB,WAC7B,WAAa,OAAOtB,EAAgB,OAAG,EACvC,WAAa,OAAOA,CAAQ,EAE7B,OADAJ,EAAoB2B,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CACR,ECNAzB,EAAoB2B,EAAI,SAASxB,EAAS0B,GACzC,IAAI,IAAIR,KAAOQ,EACX7B,EAAoB8B,EAAED,EAAYR,KAASrB,EAAoB8B,EAAE3B,EAASkB,IAC5EjC,OAAO2C,eAAe5B,EAASkB,EAAK,CAAEW,YAAY,EAAMC,IAAKJ,EAAWR,IAG3E,ECPArB,EAAoBkC,EAAI,CAAC,EAGzBlC,EAAoBmC,EAAI,SAASC,GAChC,OAAOC,QAAQC,IAAIlD,OAAO+B,KAAKnB,EAAoBkC,GAAGK,QAAO,SAASC,EAAUnB,GAE/E,OADArB,EAAoBkC,EAAEb,GAAKe,EAASI,GAC7BA,CACR,GAAG,IACJ,ECPAxC,EAAoByC,EAAI,SAASL,GAEhC,OAAYA,EAAU,IAAM,CAAC,IAAM,cAAc,KAAO,eAAeA,GAAW,KACnF,ECJApC,EAAoB8B,EAAI,SAASY,EAAKC,GAAQ,OAAOvD,OAAOwD,UAAUC,eAAeC,KAAKJ,EAAKC,EAAO,ECAtG3C,EAAoB+C,EAAI,gC,WCIxB,IAAIC,EAAkB,CACrB,KAAM,GAkBPhD,EAAoBkC,EAAElB,EAAI,SAASoB,EAASI,GAEvCQ,EAAgBZ,IAElBa,cAAcjD,EAAoB+C,EAAI/C,EAAoByC,EAAEL,GAG/D,EAEA,IAAIc,EAAqBC,KAA0C,oCAAIA,KAA0C,qCAAK,GAClHC,EAA6BF,EAAmBG,KAAKC,KAAKJ,GAC9DA,EAAmBG,KAzBA,SAASE,GAC3B,IAAI5C,EAAW4C,EAAK,GAChBC,EAAcD,EAAK,GACnBE,EAAUF,EAAK,GACnB,IAAI,IAAItD,KAAYuD,EAChBxD,EAAoB8B,EAAE0B,EAAavD,KACrCD,EAAoBM,EAAEL,GAAYuD,EAAYvD,IAIhD,IADGwD,GAASA,EAAQzD,GACdW,EAASzB,QACd8D,EAAgBrC,EAAS+C,OAAS,EACnCN,EAA2BG,EAC5B,C,ITtBInF,EAAO4B,EAAoBO,EAC/BP,EAAoBO,EAAI,WACvB,OAAO8B,QAAQC,IAAI,CAClBtC,EAAoBmC,EAAE,MACtBnC,EAAoBmC,EAAE,OACpBwB,KAAKvF,EACT,EUL0B4B,EAAoBO,G","sources":["no-source/webpack/runtime/chunk loaded","no-source/webpack/runtime/startup chunk dependencies","https://raw.githubusercontent.com/home-assistant/frontend/20230703.0/src/resources/markdown-worker.ts","no-source/webpack/bootstrap","no-source/webpack/runtime/compat get default export","no-source/webpack/runtime/define property getters","no-source/webpack/runtime/ensure chunk","no-source/webpack/runtime/get javascript chunk filename","no-source/webpack/runtime/hasOwnProperty shorthand","no-source/webpack/runtime/publicPath","no-source/webpack/runtime/importScripts chunk loading","no-source/webpack/startup"],"names":["deferred","next","whiteListNormal","whiteListSvg","onTagAttr","tag","name","value","api","renderMarkdown","content","markedOptions","whiteList","hassOptions","arguments","length","undefined","Object","assign","getDefaultWhiteList","input","allowSvg","svg","path","img","filterXSS","marked","expose","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","exports","module","__webpack_modules__","m","x","__webpack_exports__","O","result","chunkIds","fn","priority","notFulfilled","Infinity","i","fulfilled","j","keys","every","key","splice","r","n","getter","__esModule","d","a","definition","o","defineProperty","enumerable","get","f","e","chunkId","Promise","all","reduce","promises","u","obj","prop","prototype","hasOwnProperty","call","p","installedChunks","importScripts","chunkLoadingGlobal","self","parentChunkLoadingFunction","push","bind","data","moreModules","runtime","pop","then"],"sourceRoot":""}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user