mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-19 22:19:21 +00:00
Compare commits
1685 Commits
249
...
faster_bac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af3256e41e | ||
|
|
a163121ad4 | ||
|
|
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 | ||
|
|
d9c4dae739 | ||
|
|
49853e92a4 | ||
|
|
19620d6808 | ||
|
|
f6bf44de1c | ||
|
|
06fae59fc8 | ||
|
|
5c25fcd84c | ||
|
|
aa5297026f | ||
|
|
841520b75e | ||
|
|
d9e20307de | ||
|
|
fda1b523ba | ||
|
|
7cccbc682c | ||
|
|
621eb4c4c0 | ||
|
|
056926242f | ||
|
|
84294f286f | ||
|
|
b6bc6b8498 | ||
|
|
845c935b39 | ||
|
|
19d8de89df | ||
|
|
cfae20a3ec | ||
|
|
6db6ab96e6 | ||
|
|
48695c6805 | ||
|
|
d74908e3b5 | ||
|
|
7e94537e36 | ||
|
|
1427e0ae96 | ||
|
|
01e27dfa2f | ||
|
|
f48249c9d1 | ||
|
|
e607d4feeb | ||
|
|
5367ac257e | ||
|
|
46dc6dc63b | ||
|
|
a59ea72c66 | ||
|
|
2daf46c444 | ||
|
|
1bf38bdc99 | ||
|
|
131909973c | ||
|
|
ecdf4e53b8 | ||
|
|
7aa039d162 | ||
|
|
3dd3340e35 | ||
|
|
2f9fc39b72 | ||
|
|
80f4309799 | ||
|
|
550fca4bcd | ||
|
|
4b500ef873 | ||
|
|
476f021fbf | ||
|
|
8393ca5b23 | ||
|
|
4eb7a60b88 | ||
|
|
2040102e21 | ||
|
|
7ee5737f75 | ||
|
|
8d499753a0 | ||
|
|
028ec277eb | ||
|
|
5552b1da49 | ||
|
|
06ab7e904f | ||
|
|
e4bf820038 | ||
|
|
c209d2fa8d | ||
|
|
784c5d3b7c | ||
|
|
a18b706f99 | ||
|
|
cd34a40dd8 | ||
|
|
ced72e1273 | ||
|
|
5416eda1d6 | ||
|
|
c76a4ff422 | ||
|
|
d558ad2d76 | ||
|
|
5f2d183b1d | ||
|
|
46e92036ec | ||
|
|
280d423bfe | ||
|
|
c4847ad10d | ||
|
|
0a7c75830b | ||
|
|
7aceb21123 | ||
|
|
9264d437b1 | ||
|
|
edc8d8960f | ||
|
|
0f4f196dc9 | ||
|
|
a027f4b5fc | ||
|
|
f025d1df05 | ||
|
|
4c560d7c54 | ||
|
|
a976ef6e67 | ||
|
|
e1b9d754af | ||
|
|
ee49935b7d | ||
|
|
36694c9ef0 | ||
|
|
bd786811a3 | ||
|
|
ffaeb2b96d | ||
|
|
517e6cb437 | ||
|
|
9479672b88 | ||
|
|
934e59596a | ||
|
|
8f4ac10361 | ||
|
|
50e0fd159f | ||
|
|
28344ff5f3 | ||
|
|
608ae14246 | ||
|
|
c0c0d44c2d | ||
|
|
5e947348ae | ||
|
|
1f4032f56f | ||
|
|
336ab0d2b1 | ||
|
|
0f9d80dde4 | ||
|
|
0fcab4d92b | ||
|
|
abd35b62c8 | ||
|
|
7b721ad8c6 | ||
|
|
37eaaf356d | ||
|
|
8cbb4b510b | ||
|
|
f71549e3df | ||
|
|
fe15bb6a30 | ||
|
|
50d36b857a | ||
|
|
db260dfbde | ||
|
|
a0c99615aa | ||
|
|
c66c806e6e | ||
|
|
a432d28ee3 | ||
|
|
742bc43500 | ||
|
|
223e2f1df5 | ||
|
|
ed9aea6219 | ||
|
|
aa7b68d4d5 | ||
|
|
8e57cd2751 | ||
|
|
64229a188e | ||
|
|
10cbbcc2de | ||
|
|
5318e4fbcd | ||
|
|
007251a04c | ||
|
|
fd4b3ee539 | ||
|
|
976ae96633 | ||
|
|
042bdcdf37 | ||
|
|
c423e9cf8e | ||
|
|
1f13d6aa91 | ||
|
|
78c09a0fa6 | ||
|
|
e6d6f2ee8c | ||
|
|
2918ef6225 | ||
|
|
35b626a1c5 | ||
|
|
6f26536d97 | ||
|
|
01064564b4 | ||
|
|
0c6c6a6620 | ||
|
|
be166d533f | ||
|
|
d3e5535221 | ||
|
|
7206213cd8 | ||
|
|
605782d707 | ||
|
|
2c387349c9 | ||
|
|
e9c9f98168 | ||
|
|
a98c7819b0 | ||
|
|
7c42c5758d | ||
|
|
c555146094 | ||
|
|
f48c9b5774 | ||
|
|
8d1732e5eb | ||
|
|
f2843db421 | ||
|
|
b513512551 | ||
|
|
1a59839b1b | ||
|
|
45861617b9 | ||
|
|
353544085e | ||
|
|
144d3921f7 | ||
|
|
e44d22880e | ||
|
|
c7692b43e8 |
@@ -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,38 @@
|
|||||||
{
|
{
|
||||||
"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",
|
},
|
||||||
|
"appPort": ["9123:8123", "7357:4357"],
|
||||||
|
"postCreateCommand": "bash devcontainer_bootstrap",
|
||||||
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
||||||
"extensions": [
|
"customizations": {
|
||||||
"ms-python.python",
|
"vscode": {
|
||||||
"ms-python.vscode-pylance",
|
"extensions": [
|
||||||
"visualstudioexptteam.vscodeintellicode",
|
"ms-python.python",
|
||||||
"esbenp.prettier-vscode"
|
"ms-python.pylint",
|
||||||
],
|
"ms-python.vscode-pylance",
|
||||||
"settings": {
|
"visualstudioexptteam.vscodeintellicode",
|
||||||
"terminal.integrated.shell.linux": "/bin/bash",
|
"esbenp.prettier-vscode"
|
||||||
"editor.formatOnPaste": false,
|
],
|
||||||
"editor.formatOnSave": true,
|
"settings": {
|
||||||
"editor.formatOnType": true,
|
"terminal.integrated.profiles.linux": {
|
||||||
"files.trimTrailingWhitespace": true,
|
"zsh": {
|
||||||
"python.pythonPath": "/usr/local/bin/python3",
|
"path": "/usr/bin/zsh"
|
||||||
"python.linting.pylintEnabled": true,
|
}
|
||||||
"python.linting.enabled": true,
|
},
|
||||||
"python.formatting.provider": "black",
|
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||||
"python.formatting.blackArgs": ["--target-version", "py38"],
|
"editor.formatOnPaste": false,
|
||||||
"python.formatting.blackPath": "/usr/local/bin/black",
|
"editor.formatOnSave": true,
|
||||||
"python.linting.banditPath": "/usr/local/bin/bandit",
|
"editor.formatOnType": true,
|
||||||
"python.linting.flake8Path": "/usr/local/bin/flake8",
|
"files.trimTrailingWhitespace": true,
|
||||||
"python.linting.mypyPath": "/usr/local/bin/mypy",
|
"python.pythonPath": "/usr/local/bin/python3",
|
||||||
"python.linting.pylintPath": "/usr/local/bin/pylint",
|
"python.formatting.provider": "black",
|
||||||
"python.linting.pydocstylePath": "/usr/local/bin/pydocstyle"
|
"python.formatting.blackArgs": ["--target-version", "py312"],
|
||||||
}
|
"python.formatting.blackPath": "/usr/local/bin/black"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mounts": ["type=volume,target=/var/lib/docker"]
|
||||||
}
|
}
|
||||||
|
|||||||
65
.github/ISSUE_TEMPLATE.md
vendored
65
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,28 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: Report a bug with the Supervisor on a supported System
|
||||||
|
about: Report an issue related to the Home Assistant Supervisor.
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
<!-- READ THIS FIRST:
|
<!-- READ THIS FIRST:
|
||||||
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
|
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
|
||||||
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/core/releases
|
|
||||||
- Do not report issues for integrations here, please refer to https://github.com/home-assistant/core/issues
|
|
||||||
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
|
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
|
||||||
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
|
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
|
||||||
- If you have a problem with an add-on, make an issue in its repository.
|
- If you have a problem with an add-on, make an issue in it's repository.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
**Home Assistant release with the issue:**
|
|
||||||
<!--
|
<!--
|
||||||
- Frontend -> Configuration -> Info
|
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.
|
||||||
- Or use this command: hass --version
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
**Operating environment (HassOS/Generic):**
|
### Describe the issue
|
||||||
<!--
|
|
||||||
Please provide details about your environment.
|
|
||||||
-->
|
|
||||||
|
|
||||||
**Supervisor logs:**
|
<!-- Provide as many details as possible. -->
|
||||||
|
|
||||||
|
### Steps to reproduce
|
||||||
|
|
||||||
|
<!-- What do you do to encounter the issue. -->
|
||||||
|
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. ...
|
||||||
|
|
||||||
|
### Enviroment details
|
||||||
|
|
||||||
|
<!-- You can find these details in the system tab of the supervisor panel, or by using the `ha` CLI. -->
|
||||||
|
|
||||||
|
- **Operating System:**: xxx
|
||||||
|
- **Supervisor version:**: xxx
|
||||||
|
- **Home Assistant version**: xxx
|
||||||
|
|
||||||
|
### Supervisor logs
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Supervisor logs</summary>
|
||||||
<!--
|
<!--
|
||||||
- Frontend -> Supervisor -> System
|
- Frontend -> Supervisor -> System
|
||||||
- Or use this command: ha supervisor logs
|
- Or use this command: ha supervisor logs
|
||||||
|
- Logs are more than just errors, even if you don't think it's important, it is.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
```
|
||||||
|
Paste supervisor logs here
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### System Information
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>System Information</summary>
|
||||||
|
<!--
|
||||||
|
- Use this command: ha info
|
||||||
|
-->
|
||||||
|
|
||||||
|
```
|
||||||
|
Paste system info here
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Description of problem:**
|
|
||||||
|
|||||||
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.
|
||||||
25
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
25
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
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
|
||||||
|
url: https://github.com/home-assistant/frontend/issues
|
||||||
|
about: The Supervisor panel is a part of the Home Assistant frontend
|
||||||
|
|
||||||
|
- name: Report incorrect or missing information on our developer documentation
|
||||||
|
url: https://github.com/home-assistant/developers.home-assistant.io/issues
|
||||||
|
about: Our documentation has its own issue tracker. Please report issues with the website there.
|
||||||
|
|
||||||
|
- name: Request a feature for the Supervisor
|
||||||
|
url: https://community.home-assistant.io/c/feature-requests
|
||||||
|
about: Request an new feature for the Supervisor.
|
||||||
|
|
||||||
|
- name: I have a question or need support
|
||||||
|
url: https://www.home-assistant.io/help
|
||||||
|
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||||
|
|
||||||
|
- name: I'm unsure where to go?
|
||||||
|
url: https://www.home-assistant.io/join-chat
|
||||||
|
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
||||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -37,6 +37,7 @@
|
|||||||
- 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:
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
|
|||||||
27
.github/lock.yml
vendored
27
.github/lock.yml
vendored
@@ -1,27 +0,0 @@
|
|||||||
# Configuration for Lock Threads - https://github.com/dessant/lock-threads
|
|
||||||
|
|
||||||
# Number of days of inactivity before a closed issue or pull request is locked
|
|
||||||
daysUntilLock: 1
|
|
||||||
|
|
||||||
# Skip issues and pull requests created before a given timestamp. Timestamp must
|
|
||||||
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
|
|
||||||
skipCreatedBefore: 2020-01-01
|
|
||||||
|
|
||||||
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
|
|
||||||
exemptLabels: []
|
|
||||||
|
|
||||||
# Label to add before locking, such as `outdated`. Set to `false` to disable
|
|
||||||
lockLabel: false
|
|
||||||
|
|
||||||
# Comment to post before locking. Set to `false` to disable
|
|
||||||
lockComment: false
|
|
||||||
|
|
||||||
# Assign `resolved` as the reason for locking. Set to `false` to disable
|
|
||||||
setLockReason: false
|
|
||||||
|
|
||||||
# Limit to only `issues` or `pulls`
|
|
||||||
only: pulls
|
|
||||||
|
|
||||||
# Optionally, specify configuration settings just for `issues` or `pulls`
|
|
||||||
issues:
|
|
||||||
daysUntilLock: 30
|
|
||||||
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
|
||||||
|
|||||||
18
.github/stale.yml
vendored
18
.github/stale.yml
vendored
@@ -1,18 +0,0 @@
|
|||||||
# Number of days of inactivity before an issue becomes stale
|
|
||||||
daysUntilStale: 60
|
|
||||||
# Number of days of inactivity before a stale issue is closed
|
|
||||||
daysUntilClose: 7
|
|
||||||
# Issues with these labels will never be considered stale
|
|
||||||
exemptLabels:
|
|
||||||
- pinned
|
|
||||||
- security
|
|
||||||
- rfc
|
|
||||||
# Label to use when marking an issue as stale
|
|
||||||
staleLabel: stale
|
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
|
||||||
markComment: >
|
|
||||||
This issue has been automatically marked as stale because it has not had
|
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
|
||||||
for your contributions.
|
|
||||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
|
||||||
closeComment: false
|
|
||||||
380
.github/workflows/builder.yml
vendored
Normal file
380
.github/workflows/builder.yml
vendored
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
name: Build supervisor
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
channel:
|
||||||
|
description: "Channel"
|
||||||
|
required: true
|
||||||
|
default: "dev"
|
||||||
|
version:
|
||||||
|
description: "Version"
|
||||||
|
required: true
|
||||||
|
publish:
|
||||||
|
description: "Publish"
|
||||||
|
required: true
|
||||||
|
default: "false"
|
||||||
|
stable:
|
||||||
|
description: "Stable"
|
||||||
|
required: true
|
||||||
|
default: "false"
|
||||||
|
pull_request:
|
||||||
|
branches: ["main"]
|
||||||
|
release:
|
||||||
|
types: ["published"]
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
paths:
|
||||||
|
- "rootfs/**"
|
||||||
|
- "supervisor/**"
|
||||||
|
- build.yaml
|
||||||
|
- Dockerfile
|
||||||
|
- requirements.txt
|
||||||
|
- setup.py
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEFAULT_PYTHON: "3.12"
|
||||||
|
BUILD_NAME: supervisor
|
||||||
|
BUILD_TYPE: supervisor
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
init:
|
||||||
|
name: Initialize build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
architectures: ${{ steps.info.outputs.architectures }}
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
channel: ${{ steps.version.outputs.channel }}
|
||||||
|
publish: ${{ steps.version.outputs.publish }}
|
||||||
|
requirements: ${{ steps.requirements.outputs.changed }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout the repository
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get information
|
||||||
|
id: info
|
||||||
|
uses: home-assistant/actions/helpers/info@master
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
uses: home-assistant/actions/helpers/version@master
|
||||||
|
with:
|
||||||
|
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:
|
||||||
|
name: Build ${{ matrix.arch }} supervisor
|
||||||
|
needs: init
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout the repository
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
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.01.0
|
||||||
|
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
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
uses: home-assistant/actions/helpers/version@master
|
||||||
|
with:
|
||||||
|
type: ${{ env.BUILD_TYPE }}
|
||||||
|
|
||||||
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
uses: actions/setup-python@v5.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
|
- name: Install Cosign
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
uses: sigstore/cosign-installer@v3.3.0
|
||||||
|
with:
|
||||||
|
cosign-release: "v2.0.2"
|
||||||
|
|
||||||
|
- 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.0.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set build arguments
|
||||||
|
if: needs.init.outputs.publish == 'false'
|
||||||
|
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build supervisor
|
||||||
|
uses: home-assistant/builder@2024.01.0
|
||||||
|
with:
|
||||||
|
args: |
|
||||||
|
$BUILD_ARGS \
|
||||||
|
--${{ matrix.arch }} \
|
||||||
|
--target /data \
|
||||||
|
--cosign \
|
||||||
|
--generic ${{ needs.init.outputs.version }}
|
||||||
|
env:
|
||||||
|
CAS_API_KEY: ${{ secrets.CAS_TOKEN }}
|
||||||
|
|
||||||
|
version:
|
||||||
|
name: Update version
|
||||||
|
needs: ["init", "run_supervisor"]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout the repository
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
|
- name: Initialize git
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
uses: home-assistant/actions/helpers/git-init@master
|
||||||
|
with:
|
||||||
|
name: ${{ secrets.GIT_NAME }}
|
||||||
|
email: ${{ secrets.GIT_EMAIL }}
|
||||||
|
token: ${{ secrets.GIT_TOKEN }}
|
||||||
|
|
||||||
|
- name: Update version file
|
||||||
|
if: needs.init.outputs.publish == 'true'
|
||||||
|
uses: home-assistant/actions/helpers/version-push@master
|
||||||
|
with:
|
||||||
|
key: ${{ env.BUILD_NAME }}
|
||||||
|
version: ${{ needs.init.outputs.version }}
|
||||||
|
channel: ${{ needs.init.outputs.channel }}
|
||||||
|
|
||||||
|
run_supervisor:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Run the Supervisor
|
||||||
|
needs: ["build", "init"]
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Checkout the repository
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
|
- name: Build the Supervisor
|
||||||
|
if: needs.init.outputs.publish != 'true'
|
||||||
|
uses: home-assistant/builder@2024.01.0
|
||||||
|
with:
|
||||||
|
args: |
|
||||||
|
--test \
|
||||||
|
--amd64 \
|
||||||
|
--target /data \
|
||||||
|
--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
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/supervisor/data
|
||||||
|
docker create --name hassio_supervisor \
|
||||||
|
--privileged \
|
||||||
|
--security-opt seccomp=unconfined \
|
||||||
|
--security-opt apparmor=unconfined \
|
||||||
|
-v /run/docker.sock:/run/docker.sock \
|
||||||
|
-v /run/dbus:/run/dbus \
|
||||||
|
-v /tmp/supervisor/data:/data \
|
||||||
|
-v /etc/machine-id:/etc/machine-id:ro \
|
||||||
|
-e SUPERVISOR_SHARE="/tmp/supervisor/data" \
|
||||||
|
-e SUPERVISOR_NAME=hassio_supervisor \
|
||||||
|
-e SUPERVISOR_DEV=1 \
|
||||||
|
-e SUPERVISOR_MACHINE="qemux86-64" \
|
||||||
|
ghcr.io/home-assistant/amd64-hassio-supervisor:runner
|
||||||
|
|
||||||
|
- name: Start the Supervisor
|
||||||
|
run: docker start hassio_supervisor
|
||||||
|
|
||||||
|
- 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: Check the Supervisor
|
||||||
|
run: |
|
||||||
|
echo "Checking supervisor info"
|
||||||
|
test=$(docker exec hassio_cli ha supervisor info --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking supervisor network info"
|
||||||
|
test=$(docker exec hassio_cli ha network info --no-progress --raw-json | jq -r '.result')
|
||||||
|
if [ "$test" != "ok" ]; then
|
||||||
|
exit 1
|
||||||
|
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
|
||||||
183
.github/workflows/ci.yaml
vendored
183
.github/workflows/ci.yaml
vendored
@@ -4,41 +4,40 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- main
|
||||||
- master
|
|
||||||
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.1.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.0.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@v3.3.3
|
||||||
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: |
|
||||||
@@ -48,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@v3.3.3
|
||||||
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: |
|
||||||
@@ -67,19 +67,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.1.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.0.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@v3.3.3
|
||||||
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: |
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
- name: Run black
|
- name: Run black
|
||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
black --target-version py38 --check supervisor tests setup.py
|
black --target-version py312 --check supervisor tests setup.py
|
||||||
|
|
||||||
lint-dockerfile:
|
lint-dockerfile:
|
||||||
name: Check Dockerfile
|
name: Check Dockerfile
|
||||||
@@ -96,7 +96,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.1.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"
|
||||||
@@ -111,19 +111,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.1.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.0.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@v3.3.3
|
||||||
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: |
|
||||||
@@ -131,9 +131,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@v3.3.3
|
||||||
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
|
||||||
@@ -155,19 +155,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.1.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.0.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@v3.3.3
|
||||||
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: |
|
||||||
@@ -187,19 +187,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.1.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.0.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@v3.3.3
|
||||||
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: |
|
||||||
@@ -207,9 +207,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@v3.3.3
|
||||||
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
|
||||||
@@ -228,19 +228,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.1.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.0.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@v3.3.3
|
||||||
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: |
|
||||||
@@ -248,9 +248,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@v3.3.3
|
||||||
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
|
||||||
@@ -272,19 +272,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.1.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.0.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@v3.3.3
|
||||||
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: |
|
||||||
@@ -304,19 +304,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.1.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.0.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@v3.3.3
|
||||||
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: |
|
||||||
@@ -324,9 +324,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@v3.3.3
|
||||||
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
|
||||||
@@ -342,25 +342,26 @@ jobs:
|
|||||||
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.1.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.0.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.3.0
|
||||||
|
with:
|
||||||
|
cosign-release: "v2.0.2"
|
||||||
- name: Restore Python virtual environment
|
- name: Restore Python virtual environment
|
||||||
id: cache-venv
|
id: cache-venv
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3.3.3
|
||||||
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: |
|
||||||
@@ -369,7 +370,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 dbus-x11
|
||||||
- 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"
|
||||||
@@ -391,7 +392,7 @@ 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.0
|
uses: actions/upload-artifact@v4.0.0
|
||||||
with:
|
with:
|
||||||
name: coverage-${{ matrix.python-version }}
|
name: coverage-${{ matrix.python-version }}
|
||||||
path: .coverage
|
path: .coverage
|
||||||
@@ -399,29 +400,29 @@ jobs:
|
|||||||
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.1.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.0.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@v3.3.3
|
||||||
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.1
|
||||||
- name: Combine coverage results
|
- name: Combine coverage results
|
||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
@@ -429,4 +430,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.14
|
uses: codecov/codecov-action@v3.1.4
|
||||||
|
|||||||
20
.github/workflows/lock.yml
vendored
Normal file
20
.github/workflows/lock.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: Lock
|
||||||
|
|
||||||
|
# yamllint disable-line rule:truthy
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lock:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: dessant/lock-threads@v5.0.1
|
||||||
|
with:
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
issue-inactive-days: "30"
|
||||||
|
exclude-issue-created-before: "2020-10-01T00:00:00Z"
|
||||||
|
issue-lock-reason: ""
|
||||||
|
pr-inactive-days: "1"
|
||||||
|
exclude-pr-created-before: "2020-11-01T00:00:00Z"
|
||||||
|
pr-lock-reason: ""
|
||||||
35
.github/workflows/release-drafter.yml
vendored
35
.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:
|
||||||
- dev
|
- 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.1.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@v5.25.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.1.1
|
||||||
- name: Sentry Release
|
- name: Sentry Release
|
||||||
uses: getsentry/action-release@v1.1
|
uses: getsentry/action-release@v1.6.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 }}
|
||||||
|
|||||||
39
.github/workflows/stale.yml
vendored
Normal file
39
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Stale
|
||||||
|
|
||||||
|
# yamllint disable-line rule:truthy
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 * * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9.0.0
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
days-before-stale: 30
|
||||||
|
days-before-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"
|
||||||
|
stale-issue-message: >
|
||||||
|
There hasn't been any activity on this issue recently. Due to the
|
||||||
|
high number of incoming GitHub notifications, we have to clean some
|
||||||
|
of the old issues, as many of them have already been resolved with
|
||||||
|
the latest updates.
|
||||||
|
|
||||||
|
Please make sure to update to the latest version and check if that
|
||||||
|
solves the issue. Let us know if that works for you by
|
||||||
|
adding a comment 👍
|
||||||
|
|
||||||
|
This issue has now been marked as stale and will be closed if no
|
||||||
|
further activity occurs. Thank you for your contributions.
|
||||||
|
|
||||||
|
stale-pr-label: "stale"
|
||||||
|
exempt-pr-labels: "no-stale,pinned,rfc,security"
|
||||||
|
stale-pr-message: >
|
||||||
|
There hasn't been any activity on this pull request recently. This
|
||||||
|
pull request has been automatically marked as stale because of that
|
||||||
|
and will be closed if no further activity occurs within 7 days.
|
||||||
|
|
||||||
|
Thank you for your contributions.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
ignored:
|
ignored:
|
||||||
- DL3018
|
- DL3003
|
||||||
- DL3006
|
- DL3006
|
||||||
- DL3013
|
- DL3013
|
||||||
|
- DL3018
|
||||||
|
- DL3042
|
||||||
- SC2155
|
- SC2155
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 20.8b1
|
rev: 23.12.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
args:
|
args:
|
||||||
- --safe
|
- --safe
|
||||||
- --quiet
|
- --quiet
|
||||||
- --target-version
|
- --target-version
|
||||||
- py38
|
- py312
|
||||||
files: ^((supervisor|tests)/.+)?[^/]+\.py$
|
files: ^((supervisor|tests)/.+)?[^/]+\.py$
|
||||||
- repo: https://gitlab.com/pycqa/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: 3.8.3
|
rev: 7.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- flake8-docstrings==1.5.0
|
- flake8-docstrings==1.7.0
|
||||||
- pydocstyle==5.0.2
|
- pydocstyle==6.3.0
|
||||||
files: ^(supervisor|script|tests)/.+\.py$
|
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
|
- repo: https://github.com/PyCQA/isort
|
||||||
rev: v4.3.21
|
rev: 5.13.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.6.2
|
rev: v3.15.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: [--py37-plus]
|
args: [--py312-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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
17
.vscode/tasks.json
vendored
17
.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
|
||||||
@@ -86,5 +86,12 @@
|
|||||||
},
|
},
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"id": "localiseToken",
|
||||||
|
"type": "promptString",
|
||||||
|
"description": "Paste your lokalise token to download frontend translations"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
29
Dockerfile
29
Dockerfile
@@ -1,38 +1,45 @@
|
|||||||
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
# Install requirements
|
# Install requirements
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN \
|
RUN \
|
||||||
export MAKEFLAGS="-j$(nproc)" \
|
export MAKEFLAGS="-j$(nproc)" \
|
||||||
&& pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links \
|
&& pip3 install --only-binary=:all: \
|
||||||
"https://wheels.home-assistant.io/alpine-$(cut -d '.' -f 1-2 < /etc/alpine-release)/${BUILD_ARCH}/" \
|
|
||||||
-r ./requirements.txt \
|
-r ./requirements.txt \
|
||||||
&& 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -10,17 +10,23 @@ 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
|
## Release
|
||||||
- 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.
|
Releases are done in 3 stages (channels) with this structure:
|
||||||
|
|
||||||
|
1. Pull requests are merged to the `main` branch.
|
||||||
|
2. A new build is pushed to the `dev` stage.
|
||||||
|
3. Releases are published.
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# https://dev.azure.com/home-assistant
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
batch: true
|
|
||||||
branches:
|
|
||||||
include:
|
|
||||||
- dev
|
|
||||||
tags:
|
|
||||||
include:
|
|
||||||
- "*"
|
|
||||||
pr: none
|
|
||||||
variables:
|
|
||||||
- name: versionBuilder
|
|
||||||
value: "7.0"
|
|
||||||
- group: docker
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
- job: "VersionValidate"
|
|
||||||
pool:
|
|
||||||
vmImage: "ubuntu-latest"
|
|
||||||
steps:
|
|
||||||
- task: UsePythonVersion@0
|
|
||||||
displayName: "Use Python 3.8"
|
|
||||||
inputs:
|
|
||||||
versionSpec: "3.8"
|
|
||||||
- script: |
|
|
||||||
setup_version="$(python setup.py -V)"
|
|
||||||
branch_version="$(Build.SourceBranchName)"
|
|
||||||
|
|
||||||
if [ "${branch_version}" == "dev" ]; then
|
|
||||||
exit 0
|
|
||||||
elif [ "${setup_version}" != "${branch_version}" ]; then
|
|
||||||
echo "Version of tag ${branch_version} don't match with ${setup_version}!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
displayName: "Check version of branch/tag"
|
|
||||||
- job: "Release"
|
|
||||||
dependsOn:
|
|
||||||
- "VersionValidate"
|
|
||||||
pool:
|
|
||||||
vmImage: "ubuntu-latest"
|
|
||||||
steps:
|
|
||||||
- script: sudo docker login -u $(dockerUser) -p $(dockerPassword)
|
|
||||||
displayName: "Docker hub login"
|
|
||||||
- script: sudo docker pull homeassistant/amd64-builder:$(versionBuilder)
|
|
||||||
displayName: "Install Builder"
|
|
||||||
- script: |
|
|
||||||
sudo docker run --rm --privileged \
|
|
||||||
-v ~/.docker:/root/.docker \
|
|
||||||
-v /run/docker.sock:/run/docker.sock:rw -v $(pwd):/data:ro \
|
|
||||||
homeassistant/amd64-builder:$(versionBuilder) \
|
|
||||||
--generic $(Build.SourceBranchName) --all -t /data
|
|
||||||
displayName: "Build Release"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# https://dev.azure.com/home-assistant
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
batch: true
|
|
||||||
branches:
|
|
||||||
include:
|
|
||||||
- dev
|
|
||||||
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.18
|
||||||
|
armhf: ghcr.io/home-assistant/armhf-base-python:3.12-alpine3.18
|
||||||
|
armv7: ghcr.io/home-assistant/armv7-base-python:3.12-alpine3.18
|
||||||
|
amd64: ghcr.io/home-assistant/amd64-base-python:3.12-alpine3.18
|
||||||
|
i386: ghcr.io/home-assistant/i386-base-python:3.12-alpine3.18
|
||||||
|
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.0.2
|
||||||
|
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: eec4a91ad8...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
|
|
||||||
112
pyproject.toml
Normal file
112
pyproject.toml
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
[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.11"
|
||||||
|
# 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 black
|
||||||
|
# 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",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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.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.isort]
|
||||||
|
multi_line_output = 3
|
||||||
|
include_trailing_comma = true
|
||||||
|
force_grid_wrap = 0
|
||||||
|
line_length = 88
|
||||||
|
indent = " "
|
||||||
|
force_sort_within_sections = true
|
||||||
|
sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
|
||||||
|
default_section = "THIRDPARTY"
|
||||||
|
forced_separate = "tests"
|
||||||
|
combine_as_imports = true
|
||||||
|
use_parentheses = true
|
||||||
|
known_first_party = ["supervisor", "tests"]
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
aiohttp==3.6.3
|
aiodns==3.1.1
|
||||||
async_timeout==3.0.1
|
aiohttp==3.9.1
|
||||||
attrs==20.2.0
|
aiohttp-fast-url-dispatcher==0.3.0
|
||||||
brotlipy==0.7.0
|
async_timeout==4.0.3
|
||||||
cchardet==2.1.6
|
atomicwrites-homeassistant==1.4.1
|
||||||
colorlog==4.4.0
|
attrs==23.2.0
|
||||||
|
awesomeversion==23.11.0
|
||||||
|
brotli==1.1.0
|
||||||
|
ciso8601==2.3.1
|
||||||
|
colorlog==6.8.0
|
||||||
cpe==1.2.1
|
cpe==1.2.1
|
||||||
cryptography==3.1
|
cryptography==41.0.7
|
||||||
debugpy==1.0.0
|
debugpy==1.8.0
|
||||||
docker==4.3.1
|
deepmerge==1.1.1
|
||||||
gitpython==3.1.9
|
dirhash==0.2.1
|
||||||
jinja2==2.11.2
|
docker==7.0.0
|
||||||
packaging==20.4
|
faust-cchardet==2.1.19
|
||||||
pulsectl==20.5.1
|
gitpython==3.1.41
|
||||||
pytz==2020.1
|
jinja2==3.1.3
|
||||||
pyudev==0.22.0
|
orjson==3.9.10
|
||||||
ruamel.yaml==0.15.100
|
pulsectl==23.5.2
|
||||||
sentry-sdk==0.18.0
|
pyudev==0.24.1
|
||||||
uvloop==0.14.0
|
PyYAML==6.0.1
|
||||||
voluptuous==0.12.0
|
securetar==2023.12.0
|
||||||
yarl==1.5.1
|
sentry-sdk==1.39.2
|
||||||
|
setuptools==69.0.3
|
||||||
|
voluptuous==0.14.1
|
||||||
|
dbus-fast==2.21.0
|
||||||
|
typing_extensions==4.9.0
|
||||||
|
zlib-fast==0.1.0
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
black==20.8b1
|
black==23.12.1
|
||||||
codecov==2.1.10
|
coverage==7.4.0
|
||||||
coverage==5.3
|
flake8-docstrings==1.7.0
|
||||||
flake8-docstrings==1.5.0
|
flake8==7.0.0
|
||||||
flake8==3.8.4
|
pre-commit==3.6.0
|
||||||
pre-commit==2.7.1
|
pydocstyle==6.3.0
|
||||||
pydocstyle==5.1.1
|
pylint==3.0.3
|
||||||
pylint==2.6.0
|
pytest-aiohttp==1.0.5
|
||||||
pytest-aiohttp==0.3.0
|
pytest-asyncio==0.23.3
|
||||||
pytest-asyncio==0.12.0 # NB!: Versions over 0.12.0 breaks pytest-aiohttp (https://github.com/aio-libs/pytest-aiohttp/issues/16)
|
pytest-cov==4.1.0
|
||||||
pytest-cov==2.10.1
|
pytest-timeout==2.2.0
|
||||||
pytest-timeout==1.4.2
|
pytest==7.4.4
|
||||||
pytest==6.1.1
|
pyupgrade==3.15.0
|
||||||
pyupgrade==2.7.2
|
time-machine==2.13.0
|
||||||
|
typing_extensions==4.9.0
|
||||||
|
urllib3==2.1.0
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ autospawn = no
|
|||||||
; daemon-binary = /usr/bin/pulseaudio
|
; daemon-binary = /usr/bin/pulseaudio
|
||||||
; extra-arguments = --log-target=syslog
|
; extra-arguments = --log-target=syslog
|
||||||
|
|
||||||
; cookie-file =
|
cookie-file = /run/pulse-cookie
|
||||||
|
|
||||||
; enable-shm = yes
|
; enable-shm = yes
|
||||||
; shm-size-bytes = 0 # setting this 0 will use the system-default, usually 64 MiB
|
; shm-size-bytes = 0 # setting this 0 will use the system-default, usually 64 MiB
|
||||||
|
|||||||
10
rootfs/etc/services.d/supervisor/finish
Normal file → Executable file
10
rootfs/etc/services.d/supervisor/finish
Normal file → Executable file
@@ -1,5 +1,11 @@
|
|||||||
#!/usr/bin/execlineb -S0
|
#!/usr/bin/env bashio
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Take down the S6 supervision tree when Supervisor fails
|
# Take down the S6 supervision tree when Supervisor fails
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
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,133 +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
|
|
||||||
|
|
||||||
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
|
||||||
17
setup.cfg
17
setup.cfg
@@ -1,18 +1,3 @@
|
|||||||
[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]
|
[flake8]
|
||||||
exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
|
exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
|
||||||
doctests = True
|
doctests = True
|
||||||
@@ -28,3 +13,5 @@ ignore =
|
|||||||
E203,
|
E203,
|
||||||
D202,
|
D202,
|
||||||
W504
|
W504
|
||||||
|
per-file-ignores =
|
||||||
|
tests/dbus_service_mocks/*.py: F821,F722
|
||||||
|
|||||||
69
setup.py
69
setup.py
@@ -1,54 +1,27 @@
|
|||||||
"""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",
|
|
||||||
"supervisor.docker",
|
|
||||||
"supervisor.addons",
|
|
||||||
"supervisor.api",
|
|
||||||
"supervisor.dbus",
|
|
||||||
"supervisor.dbus.payloads",
|
|
||||||
"supervisor.dbus.network",
|
|
||||||
"supervisor.discovery",
|
|
||||||
"supervisor.discovery.services",
|
|
||||||
"supervisor.services",
|
|
||||||
"supervisor.services.modules",
|
|
||||||
"supervisor.homeassistant",
|
|
||||||
"supervisor.host",
|
|
||||||
"supervisor.misc",
|
|
||||||
"supervisor.utils",
|
|
||||||
"supervisor.plugins",
|
|
||||||
"supervisor.snapshots",
|
|
||||||
"supervisor.store",
|
|
||||||
],
|
|
||||||
include_package_data=True,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,24 +2,31 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import logging
|
import logging
|
||||||
|
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 # noqa: E402
|
||||||
|
from supervisor.utils.logging import activate_log_queue_handler # noqa: E402
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONTAINER_OS_STARTUP_CHECK = Path("/run/os/startup-marker")
|
||||||
|
|
||||||
|
|
||||||
|
def run_os_startup_check_cleanup() -> None:
|
||||||
|
"""Cleanup OS startup check."""
|
||||||
|
if not CONTAINER_OS_STARTUP_CHECK.exists():
|
||||||
|
return
|
||||||
|
|
||||||
def initialize_event_loop():
|
|
||||||
"""Attempt to use uvloop."""
|
|
||||||
try:
|
try:
|
||||||
# pylint: disable=import-outside-toplevel
|
CONTAINER_OS_STARTUP_CHECK.unlink()
|
||||||
import uvloop
|
except OSError as err:
|
||||||
|
_LOGGER.warning("Not able to remove the startup health file: %s", err)
|
||||||
uvloop.install()
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return asyncio.get_event_loop()
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@@ -27,7 +34,8 @@ if __name__ == "__main__":
|
|||||||
bootstrap.initialize_logging()
|
bootstrap.initialize_logging()
|
||||||
|
|
||||||
# Init async event loop
|
# Init async event loop
|
||||||
loop = initialize_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()
|
||||||
@@ -36,13 +44,19 @@ 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)
|
||||||
bootstrap.migrate_system_env(coresys)
|
bootstrap.migrate_system_env(coresys)
|
||||||
|
|
||||||
|
# Signal health startup for container
|
||||||
|
run_os_startup_check_cleanup()
|
||||||
|
|
||||||
_LOGGER.info("Setting up Supervisor")
|
_LOGGER.info("Setting up Supervisor")
|
||||||
loop.run_until_complete(coresys.core.setup())
|
loop.run_until_complete(coresys.core.setup())
|
||||||
|
|
||||||
@@ -56,4 +70,4 @@ if __name__ == "__main__":
|
|||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
_LOGGER.info("Closing Supervisor")
|
_LOGGER.info("Closing Supervisor")
|
||||||
sys.exit(0)
|
sys.exit(coresys.core.exit_code)
|
||||||
|
|||||||
@@ -1,392 +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 ..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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
await self.sys_run_in_executor(
|
|
||||||
self.sys_docker.network.stale_cleanup, addon.instance.name
|
|
||||||
)
|
|
||||||
|
|
||||||
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_core.healthy = False
|
|
||||||
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,31 @@
|
|||||||
"""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 +33,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 +80,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):
|
||||||
"""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"{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
|
||||||
47
supervisor/addons/const.py
Normal file
47
supervisor/addons/const.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""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_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,6 @@
|
|||||||
"""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 +12,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):
|
||||||
|
|||||||
374
supervisor/addons/manager.py
Normal file
374
supervisor/addons/manager.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
"""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."""
|
||||||
|
tasks = []
|
||||||
|
for slug in self.data.system:
|
||||||
|
addon = self.local[slug] = Addon(self.coresys, slug)
|
||||||
|
tasks.append(self.sys_create_task(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
|
||||||
|
# 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) -> None:
|
||||||
|
"""Remove an add-on."""
|
||||||
|
if slug not in self.local:
|
||||||
|
_LOGGER.warning("Add-on %s is not installed", slug)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.local[slug].uninstall()
|
||||||
|
|
||||||
|
_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,13 @@
|
|||||||
"""Init file for Supervisor add-ons."""
|
"""Init file for Supervisor add-ons."""
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Awaitable, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
from packaging import version as pkg_version
|
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
ATTR_ADVANCED,
|
ATTR_ADVANCED,
|
||||||
@@ -12,7 +15,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 +35,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,16 +54,19 @@ 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,
|
||||||
@@ -70,18 +81,37 @@ from ..const import (
|
|||||||
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_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
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -105,7 +135,7 @@ 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]
|
||||||
|
|
||||||
@@ -115,7 +145,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
return self.data[ATTR_BOOT]
|
return self.data[ATTR_BOOT]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auto_update(self) -> Optional[bool]:
|
def auto_update(self) -> bool | None:
|
||||||
"""Return if auto update is enable."""
|
"""Return if auto update is enable."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -130,7 +160,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 +170,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 +195,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 +204,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 +212,17 @@ 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 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 +247,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 +260,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 +324,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 +384,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 +419,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 +441,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 +453,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 +468,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,12 +489,12 @@ 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)
|
||||||
|
|
||||||
@@ -455,17 +519,25 @@ class AddonModel(CoreSysAttributes, ABC):
|
|||||||
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 +547,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 +588,41 @@ 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)
|
||||||
|
|
||||||
|
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,30 +630,46 @@ 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:
|
||||||
"""Generate image name from data."""
|
"""Generate image name from data."""
|
||||||
@@ -574,19 +680,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)
|
|
||||||
|
|||||||
422
supervisor/addons/options.py
Normal file
422
supervisor/addons/options.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""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
|
||||||
@@ -6,18 +6,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 +16,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 +35,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 (
|
||||||
privilege in addon.privileged
|
any(
|
||||||
for privilege in (
|
privilege in addon.privileged
|
||||||
PRIVILEGED_NET_ADMIN,
|
for privilege in (
|
||||||
PRIVILEGED_SYS_ADMIN,
|
Capabilities.BPF,
|
||||||
PRIVILEGED_SYS_RAWIO,
|
Capabilities.DAC_READ_SEARCH,
|
||||||
PRIVILEGED_SYS_PTRACE,
|
Capabilities.NET_ADMIN,
|
||||||
PRIVILEGED_SYS_MODULE,
|
Capabilities.NET_RAW,
|
||||||
PRIVILEGED_DAC_READ_SEARCH,
|
Capabilities.PERFMON,
|
||||||
|
Capabilities.SYS_ADMIN,
|
||||||
|
Capabilities.SYS_MODULE,
|
||||||
|
Capabilities.SYS_PTRACE,
|
||||||
|
Capabilities.SYS_RAWIO,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
or addon.with_kernel_modules
|
||||||
):
|
):
|
||||||
rating += -1
|
rating += -1
|
||||||
|
|
||||||
@@ -73,15 +73,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:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
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 +18,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 +41,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,11 +67,11 @@ 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,
|
||||||
@@ -73,6 +80,9 @@ from ..const import (
|
|||||||
ATTR_SYSTEM,
|
ATTR_SYSTEM,
|
||||||
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,7 +92,6 @@ 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,
|
||||||
@@ -90,9 +99,10 @@ from ..const import (
|
|||||||
AddonStartup,
|
AddonStartup,
|
||||||
AddonState,
|
AddonState,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSys
|
|
||||||
from ..discovery.validate import valid_discovery_service
|
from ..discovery.validate import valid_discovery_service
|
||||||
|
from ..docker.const import Capabilities
|
||||||
from ..validate import (
|
from ..validate import (
|
||||||
|
docker_image,
|
||||||
docker_ports,
|
docker_ports,
|
||||||
docker_ports_description,
|
docker_ports_description,
|
||||||
network_port,
|
network_port,
|
||||||
@@ -100,51 +110,25 @@ from ..validate import (
|
|||||||
uuid_match,
|
uuid_match,
|
||||||
version_tag,
|
version_tag,
|
||||||
)
|
)
|
||||||
|
from .const import (
|
||||||
|
ATTR_BACKUP,
|
||||||
|
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,7 +138,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-m1"
|
||||||
r"|odroid-n2"
|
r"|odroid-n2"
|
||||||
r"|odroid-xu"
|
r"|odroid-xu"
|
||||||
r"|qemuarm-64"
|
r"|qemuarm-64"
|
||||||
@@ -167,39 +154,192 @@ 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]):
|
||||||
if value == "before":
|
"""Warn about miss configs."""
|
||||||
return AddonStartup.SERVICES.value
|
name = config.get(ATTR_NAME)
|
||||||
if value == "after":
|
if not name:
|
||||||
return AddonStartup.APPLICATION.value
|
raise vol.Invalid("Invalid Add-on config!")
|
||||||
return value
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid_services: list[str] = []
|
||||||
|
for service in config.get(ATTR_DISCOVERY, []):
|
||||||
|
try:
|
||||||
|
valid_discovery_service(service)
|
||||||
|
except vol.Invalid:
|
||||||
|
invalid_services.append(service)
|
||||||
|
|
||||||
|
if invalid_services:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Add-on lists the following unknown services for discovery: %s. Please report this to the maintainer of %s",
|
||||||
|
", ".join(invalid_services),
|
||||||
|
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":
|
||||||
|
config[ATTR_STARTUP] = AddonStartup.SERVICES
|
||||||
|
elif value == "after":
|
||||||
|
config[ATTR_STARTUP] = AddonStartup.APPLICATION
|
||||||
|
|
||||||
|
# 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=AddonBoot.AUTO): vol.Coerce(AddonBoot),
|
||||||
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+\].*$"
|
||||||
@@ -208,30 +348,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(),
|
||||||
@@ -240,51 +391,73 @@ 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(),
|
||||||
},
|
},
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -293,19 +466,17 @@ 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(),
|
||||||
@@ -313,285 +484,35 @@ SCHEMA_ADDON_USER = vol.Schema(
|
|||||||
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_REPOSITORY): vol.Coerce(str),
|
vol.Required(ATTR_LOCATON): 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,14 +1,19 @@
|
|||||||
"""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 aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher
|
||||||
|
|
||||||
|
from ..const import AddonState
|
||||||
from ..coresys import CoreSys, CoreSysAttributes
|
from ..coresys import CoreSys, CoreSysAttributes
|
||||||
|
from ..exceptions import APIAddonNotInstalled
|
||||||
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 .discovery import APIDiscovery
|
from .discovery import APIDiscovery
|
||||||
from .dns import APICoreDNS
|
from .dns import APICoreDNS
|
||||||
@@ -16,23 +21,28 @@ 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 .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
|
||||||
|
|
||||||
_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):
|
||||||
@@ -45,20 +55,28 @@ 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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
attach_fast_url_dispatcher(self.webapp, FastUrlDispatcher())
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Register REST API Calls."""
|
"""Register REST API Calls."""
|
||||||
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()
|
||||||
@@ -66,8 +84,9 @@ 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()
|
||||||
@@ -75,10 +94,14 @@ class RestAPI(CoreSysAttributes):
|
|||||||
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()
|
||||||
|
|
||||||
def _register_host(self) -> None:
|
def _register_host(self) -> None:
|
||||||
"""Register hostcontrol functions."""
|
"""Register hostcontrol functions."""
|
||||||
api_host = APIHost()
|
api_host = APIHost()
|
||||||
@@ -87,16 +110,36 @@ class RestAPI(CoreSysAttributes):
|
|||||||
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),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,6 +151,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
|
||||||
),
|
),
|
||||||
@@ -115,6 +159,14 @@ class RestAPI(CoreSysAttributes):
|
|||||||
"/network/interface/{interface}/update",
|
"/network/interface/{interface}/update",
|
||||||
api_network.interface_update,
|
api_network.interface_update,
|
||||||
),
|
),
|
||||||
|
web.get(
|
||||||
|
"/network/interface/{interface}/accesspoints",
|
||||||
|
api_network.scan_accesspoints,
|
||||||
|
),
|
||||||
|
web.post(
|
||||||
|
"/network/interface/{interface}/vlan/{vlan}",
|
||||||
|
api_network.create_vlan,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +180,45 @@ 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),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _register_jobs(self) -> None:
|
||||||
|
"""Register Jobs functions."""
|
||||||
|
api_jobs = APIJobs()
|
||||||
|
api_jobs.coresys = self.coresys
|
||||||
|
|
||||||
|
self.webapp.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/jobs/info", api_jobs.info),
|
||||||
|
web.post("/jobs/options", api_jobs.options),
|
||||||
|
web.post("/jobs/reset", api_jobs.reset),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -181,16 +272,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."""
|
||||||
@@ -200,6 +299,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,
|
||||||
@@ -212,6 +315,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),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -222,6 +330,7 @@ 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),
|
||||||
@@ -241,6 +350,7 @@ class RestAPI(CoreSysAttributes):
|
|||||||
web.get("/supervisor/logs", api_supervisor.logs),
|
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/options", api_supervisor.options),
|
web.post("/supervisor/options", api_supervisor.options),
|
||||||
web.post("/supervisor/repair", api_supervisor.repair),
|
web.post("/supervisor/repair", api_supervisor.repair),
|
||||||
]
|
]
|
||||||
@@ -263,17 +373,22 @@ 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
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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/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),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -290,7 +405,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),
|
||||||
@@ -308,30 +428,42 @@ 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(
|
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}/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),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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()
|
||||||
@@ -340,35 +472,36 @@ class RestAPI(CoreSysAttributes):
|
|||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
web.post("/ingress/session", api_ingress.create_session),
|
web.post("/ingress/session", api_ingress.create_session),
|
||||||
|
web.post("/ingress/validate_session", api_ingress.validate_session),
|
||||||
web.get("/ingress/panels", api_ingress.panels),
|
web.get("/ingress/panels", api_ingress.panels),
|
||||||
web.view("/ingress/{token}/{path:.*}", api_ingress.handler),
|
web.view("/ingress/{token}/{path:.*}", api_ingress.handler),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
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),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -421,6 +554,8 @@ class RestAPI(CoreSysAttributes):
|
|||||||
"""Register Audio functions."""
|
"""Register Audio functions."""
|
||||||
api_audio = APIAudio()
|
api_audio = APIAudio()
|
||||||
api_audio.coresys = self.coresys
|
api_audio.coresys = self.coresys
|
||||||
|
api_host = APIHost()
|
||||||
|
api_host.coresys = self.coresys
|
||||||
|
|
||||||
self.webapp.add_routes(
|
self.webapp.add_routes(
|
||||||
[
|
[
|
||||||
@@ -439,6 +574,83 @@ class RestAPI(CoreSysAttributes):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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}/{version}", 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,
|
||||||
|
),
|
||||||
|
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")
|
||||||
@@ -461,9 +673,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,15 @@
|
|||||||
"""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 +46,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 +54,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,18 +71,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_TRANSLATIONS,
|
||||||
|
ATTR_UART,
|
||||||
ATTR_UDEV,
|
ATTR_UDEV,
|
||||||
|
ATTR_UPDATE_AVAILABLE,
|
||||||
ATTR_URL,
|
ATTR_URL,
|
||||||
ATTR_USB,
|
ATTR_USB,
|
||||||
ATTR_VALID,
|
ATTR_VALID,
|
||||||
@@ -91,22 +93,25 @@ 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,
|
|
||||||
)
|
)
|
||||||
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_SIGNED, CONTENT_TYPE_BINARY
|
||||||
|
from .utils import api_process, api_process_raw, 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(
|
||||||
@@ -114,8 +119,8 @@ 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(),
|
||||||
}
|
}
|
||||||
@@ -128,7 +133,7 @@ SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
|
|||||||
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 _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."""
|
||||||
addon_slug: str = request.match_info.get("addon")
|
addon_slug: str = request.match_info.get("addon")
|
||||||
|
|
||||||
@@ -142,17 +147,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 = [
|
||||||
{
|
{
|
||||||
@@ -161,38 +162,30 @@ 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.latest_version,
|
ATTR_VERSION: addon.version,
|
||||||
ATTR_INSTALLED: addon.version if addon.is_installed else None,
|
ATTR_VERSION_LATEST: addon.latest_version,
|
||||||
|
ATTR_UPDATE_AVAILABLE: addon.need_update,
|
||||||
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,
|
||||||
}
|
}
|
||||||
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._extract_addon(request)
|
||||||
|
|
||||||
@@ -205,9 +198,7 @@ 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_PROTECTED: addon.protected,
|
ATTR_PROTECTED: addon.protected,
|
||||||
ATTR_RATING: rating_security(addon),
|
ATTR_RATING: rating_security(addon),
|
||||||
@@ -218,7 +209,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,
|
||||||
@@ -227,74 +217,65 @@ 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_STATE: addon.state,
|
||||||
ATTR_INGRESS_PORT: None,
|
ATTR_WEBUI: addon.webui,
|
||||||
ATTR_INGRESS_PANEL: None,
|
ATTR_INGRESS_ENTRY: addon.ingress_entry,
|
||||||
ATTR_WATCHDOG: None,
|
ATTR_INGRESS_URL: addon.ingress_url,
|
||||||
|
ATTR_INGRESS_PORT: addon.ingress_port,
|
||||||
|
ATTR_INGRESS_PANEL: addon.ingress_panel,
|
||||||
|
ATTR_AUDIO_INPUT: addon.audio_input,
|
||||||
|
ATTR_AUDIO_OUTPUT: addon.audio_output,
|
||||||
|
ATTR_AUTO_UPDATE: addon.auto_update,
|
||||||
|
ATTR_IP_ADDRESS: str(addon.ip_address),
|
||||||
|
ATTR_VERSION: addon.version,
|
||||||
|
ATTR_UPDATE_AVAILABLE: addon.need_update,
|
||||||
|
ATTR_WATCHDOG: addon.watchdog,
|
||||||
|
ATTR_DEVICES: addon.static_devices
|
||||||
|
+ [device.path for device in addon.devices],
|
||||||
}
|
}
|
||||||
|
|
||||||
if isinstance(addon, Addon) and addon.is_installed:
|
|
||||||
data.update(
|
|
||||||
{
|
|
||||||
ATTR_STATE: addon.state,
|
|
||||||
ATTR_WEBUI: addon.webui,
|
|
||||||
ATTR_INGRESS_ENTRY: addon.ingress_entry,
|
|
||||||
ATTR_INGRESS_URL: addon.ingress_url,
|
|
||||||
ATTR_INGRESS_PORT: addon.ingress_port,
|
|
||||||
ATTR_INGRESS_PANEL: addon.ingress_panel,
|
|
||||||
ATTR_AUDIO_INPUT: addon.audio_input,
|
|
||||||
ATTR_AUDIO_OUTPUT: addon.audio_output,
|
|
||||||
ATTR_AUTO_UPDATE: addon.auto_update,
|
|
||||||
ATTR_IP_ADDRESS: str(addon.ip_address),
|
|
||||||
ATTR_VERSION: addon.version,
|
|
||||||
ATTR_WATCHDOG: addon.watchdog,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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._extract_addon(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
|
||||||
@@ -322,21 +303,62 @@ class APIAddons(CoreSysAttributes):
|
|||||||
@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._extract_addon(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._extract_addon(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._extract_addon(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)
|
||||||
@@ -345,9 +367,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._extract_addon(request)
|
||||||
|
|
||||||
stats: DockerStats = await addon.stats()
|
stats: DockerStats = await addon.stats()
|
||||||
|
|
||||||
@@ -362,98 +384,49 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_BLK_WRITE: stats.blk_write,
|
ATTR_BLK_WRITE: stats.blk_write,
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
|
||||||
def install(self, request: web.Request) -> Awaitable[None]:
|
|
||||||
"""Install add-on."""
|
|
||||||
addon = self._extract_addon(request)
|
|
||||||
return asyncio.shield(addon.install())
|
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
def uninstall(self, request: web.Request) -> Awaitable[None]:
|
def uninstall(self, request: web.Request) -> Awaitable[None]:
|
||||||
"""Uninstall add-on."""
|
"""Uninstall add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self._extract_addon(request)
|
||||||
return asyncio.shield(addon.uninstall())
|
return asyncio.shield(self.sys_addons.uninstall(addon.slug))
|
||||||
|
|
||||||
@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._extract_addon(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._extract_addon(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._extract_addon(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._extract_addon(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)
|
@api_process_raw(CONTENT_TYPE_BINARY)
|
||||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
||||||
"""Return logs from add-on."""
|
"""Return logs from add-on."""
|
||||||
addon = self._extract_addon_installed(request)
|
addon = self._extract_addon(request)
|
||||||
return addon.logs()
|
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._extract_addon(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")
|
||||||
|
|
||||||
@@ -461,14 +434,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,11 @@
|
|||||||
"""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 (
|
||||||
@@ -25,15 +26,16 @@ from ..const import (
|
|||||||
ATTR_NETWORK_RX,
|
ATTR_NETWORK_RX,
|
||||||
ATTR_NETWORK_TX,
|
ATTR_NETWORK_TX,
|
||||||
ATTR_OUTPUT,
|
ATTR_OUTPUT,
|
||||||
|
ATTR_UPDATE_AVAILABLE,
|
||||||
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 .const import CONTENT_TYPE_BINARY
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .utils import api_process, api_process_raw, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
@@ -55,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}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,28 +68,25 @@ 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,
|
||||||
ATTR_VERSION_LATEST: self.sys_plugins.audio.latest_version,
|
ATTR_VERSION_LATEST: self.sys_plugins.audio.latest_version,
|
||||||
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""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 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 +8,26 @@ 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_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 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 +41,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 +62,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 +76,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])
|
||||||
)
|
)
|
||||||
|
|||||||
305
supervisor/api/backups.py
Normal file
305
supervisor/api/backups.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""Backups RESTful API."""
|
||||||
|
import asyncio
|
||||||
|
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.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,
|
||||||
|
)
|
||||||
|
from ..coresys import CoreSysAttributes
|
||||||
|
from ..exceptions import APIError
|
||||||
|
from ..mounts.const import MountUsage
|
||||||
|
from ..resolution.const import UnhealthyReason
|
||||||
|
from .const import 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_PARTIAL = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_PASSWORD): vol.Maybe(str),
|
||||||
|
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_RESTORE_FULL = vol.Schema({vol.Optional(ATTR_PASSWORD): vol.Maybe(str)})
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def backup_full(self, request):
|
||||||
|
"""Create full backup."""
|
||||||
|
body = await api_validate(SCHEMA_BACKUP_FULL, request)
|
||||||
|
|
||||||
|
backup = await asyncio.shield(
|
||||||
|
self.sys_backups.do_backup_full(**self._location_to_mount(body))
|
||||||
|
)
|
||||||
|
|
||||||
|
if backup:
|
||||||
|
return {ATTR_SLUG: backup.slug}
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def backup_partial(self, request):
|
||||||
|
"""Create a partial backup."""
|
||||||
|
body = await api_validate(SCHEMA_BACKUP_PARTIAL, request)
|
||||||
|
backup = await asyncio.shield(
|
||||||
|
self.sys_backups.do_backup_partial(**self._location_to_mount(body))
|
||||||
|
)
|
||||||
|
|
||||||
|
if backup:
|
||||||
|
return {ATTR_SLUG: backup.slug}
|
||||||
|
return False
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
return await asyncio.shield(self.sys_backups.do_restore_full(backup, **body))
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
return await asyncio.shield(self.sys_backups.do_restore_partial(backup, **body))
|
||||||
|
|
||||||
|
@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,7 @@
|
|||||||
"""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
|
||||||
@@ -15,6 +15,7 @@ from ..const import (
|
|||||||
ATTR_MEMORY_USAGE,
|
ATTR_MEMORY_USAGE,
|
||||||
ATTR_NETWORK_RX,
|
ATTR_NETWORK_RX,
|
||||||
ATTR_NETWORK_TX,
|
ATTR_NETWORK_TX,
|
||||||
|
ATTR_UPDATE_AVAILABLE,
|
||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
ATTR_VERSION_LATEST,
|
ATTR_VERSION_LATEST,
|
||||||
)
|
)
|
||||||
@@ -31,15 +32,16 @@ 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,
|
||||||
ATTR_VERSION_LATEST: self.sys_plugins.cli.latest_version,
|
ATTR_VERSION_LATEST: self.sys_plugins.cli.latest_version,
|
||||||
|
ATTR_UPDATE_AVAILABLE: self.sys_plugins.cli.need_update,
|
||||||
}
|
}
|
||||||
|
|
||||||
@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()
|
||||||
|
|
||||||
|
|||||||
54
supervisor/api/const.py
Normal file
54
supervisor/api/const.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Const for API."""
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
COOKIE_INGRESS = "ingress_session"
|
||||||
|
|
||||||
|
ATTR_AGENT_VERSION = "agent_version"
|
||||||
|
ATTR_APPARMOR_VERSION = "apparmor_version"
|
||||||
|
ATTR_ATTRIBUTES = "attributes"
|
||||||
|
ATTR_AVAILABLE_UPDATES = "available_updates"
|
||||||
|
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_IDENTIFIERS = "identifiers"
|
||||||
|
ATTR_JOBS = "jobs"
|
||||||
|
ATTR_LLMNR = "llmnr"
|
||||||
|
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
|
||||||
|
ATTR_MDNS = "mdns"
|
||||||
|
ATTR_MODEL = "model"
|
||||||
|
ATTR_MOUNTS = "mounts"
|
||||||
|
ATTR_MOUNT_POINTS = "mount_points"
|
||||||
|
ATTR_PANEL_PATH = "panel_path"
|
||||||
|
ATTR_REMOVABLE = "removable"
|
||||||
|
ATTR_REVISION = "revision"
|
||||||
|
ATTR_SEAT = "seat"
|
||||||
|
ATTR_SIGNED = "signed"
|
||||||
|
ATTR_STARTUP_TIME = "startup_time"
|
||||||
|
ATTR_SUBSYSTEM = "subsystem"
|
||||||
|
ATTR_SYSFS = "sysfs"
|
||||||
|
ATTR_SYSTEM_HEALTH_LED = "system_health_led"
|
||||||
|
ATTR_TIME_DETECTED = "time_detected"
|
||||||
|
ATTR_UPDATE_TYPE = "update_type"
|
||||||
|
ATTR_USE_NTP = "use_ntp"
|
||||||
|
ATTR_USAGE = "usage"
|
||||||
|
ATTR_VENDOR = "vendor"
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"""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,15 +12,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 ..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.Optional(ATTR_CONFIG): vol.Maybe(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_SERVICE: message.service,
|
||||||
ATTR_ADDON: message.addon,
|
ATTR_UUID: message.uuid,
|
||||||
ATTR_SERVICE: message.service,
|
ATTR_CONFIG: message.config,
|
||||||
ATTR_UUID: message.uuid,
|
}
|
||||||
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,28 @@ 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]
|
||||||
|
|
||||||
|
try:
|
||||||
|
valid_discovery_service(service)
|
||||||
|
except vol.Invalid:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Received discovery message for unknown service %s from addon %s. Please report this to the maintainer of the add-on",
|
||||||
|
service,
|
||||||
|
addon.name,
|
||||||
|
)
|
||||||
|
|
||||||
# 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 +97,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,8 @@
|
|||||||
"""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
|
||||||
@@ -18,19 +19,25 @@ from ..const import (
|
|||||||
ATTR_NETWORK_RX,
|
ATTR_NETWORK_RX,
|
||||||
ATTR_NETWORK_TX,
|
ATTR_NETWORK_TX,
|
||||||
ATTR_SERVERS,
|
ATTR_SERVERS,
|
||||||
|
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 .const import ATTR_FALLBACK, ATTR_LLMNR, ATTR_MDNS, CONTENT_TYPE_BINARY
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .utils import api_process, api_process_raw, 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})
|
||||||
|
|
||||||
@@ -39,29 +46,41 @@ 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,
|
||||||
ATTR_VERSION_LATEST: self.sys_plugins.dns.latest_version,
|
ATTR_VERSION_LATEST: self.sys_plugins.dns.latest_version,
|
||||||
|
ATTR_UPDATE_AVAILABLE: self.sys_plugins.dns.need_update,
|
||||||
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_host.network.dns_servers,
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""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 +21,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 +33,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,117 @@
|
|||||||
"""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 UDisks2
|
||||||
|
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: UDisks2, 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 +125,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,8 @@
|
|||||||
"""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 +11,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,
|
||||||
@@ -25,15 +28,15 @@ from ..const import (
|
|||||||
ATTR_PORT,
|
ATTR_PORT,
|
||||||
ATTR_REFRESH_TOKEN,
|
ATTR_REFRESH_TOKEN,
|
||||||
ATTR_SSL,
|
ATTR_SSL,
|
||||||
|
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 APIError
|
||||||
from ..validate import docker_image, network_port, version_tag
|
from ..validate import docker_image, network_port, version_tag
|
||||||
|
from .const import CONTENT_TYPE_BINARY
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .utils import api_process, api_process_raw, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
@@ -42,29 +45,35 @@ _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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class APIHomeAssistant(CoreSysAttributes):
|
class APIHomeAssistant(CoreSysAttributes):
|
||||||
"""Handle RESTful API for Home Assistant functions."""
|
"""Handle RESTful API for Home Assistant 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 host information."""
|
"""Return host information."""
|
||||||
return {
|
return {
|
||||||
ATTR_VERSION: self.sys_homeassistant.version,
|
ATTR_VERSION: self.sys_homeassistant.version,
|
||||||
ATTR_VERSION_LATEST: self.sys_homeassistant.latest_version,
|
ATTR_VERSION_LATEST: self.sys_homeassistant.latest_version,
|
||||||
|
ATTR_UPDATE_AVAILABLE: self.sys_homeassistant.need_update,
|
||||||
ATTR_MACHINE: self.sys_homeassistant.machine,
|
ATTR_MACHINE: self.sys_homeassistant.machine,
|
||||||
ATTR_IP_ADDRESS: str(self.sys_homeassistant.ip_address),
|
ATTR_IP_ADDRESS: str(self.sys_homeassistant.ip_address),
|
||||||
ATTR_ARCH: self.sys_homeassistant.arch,
|
ATTR_ARCH: self.sys_homeassistant.arch,
|
||||||
@@ -73,11 +82,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
|
||||||
@@ -100,9 +107,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]
|
||||||
|
|
||||||
@@ -112,10 +116,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:
|
||||||
@@ -135,10 +144,14 @@ 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 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]:
|
def stop(self, request: web.Request) -> Awaitable[None]:
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""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 +14,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,14 +24,35 @@ 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 .utils import api_process, api_process_raw, api_validate
|
from ..exceptions import APIError, HostLogError
|
||||||
|
from ..host.const import PARAM_BOOT_ID, PARAM_FOLLOW, PARAM_SYSLOG_IDENTIFIER
|
||||||
|
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_IDENTIFIERS,
|
||||||
|
ATTR_LLMNR_HOSTNAME,
|
||||||
|
ATTR_STARTUP_TIME,
|
||||||
|
ATTR_USE_NTP,
|
||||||
|
CONTENT_TYPE_TEXT,
|
||||||
|
)
|
||||||
|
from .utils import api_process, 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_RANGE = 100
|
||||||
|
|
||||||
|
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_HOSTNAME): str})
|
||||||
|
|
||||||
|
|
||||||
class APIHost(CoreSysAttributes):
|
class APIHost(CoreSysAttributes):
|
||||||
@@ -37,16 +62,28 @@ class APIHost(CoreSysAttributes):
|
|||||||
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_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_FEATURES: self.sys_host.supported_features,
|
ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time,
|
||||||
|
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
|
||||||
@@ -91,30 +128,75 @@ 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))
|
|
||||||
|
async def _get_boot_id(self, possible_offset: str) -> str:
|
||||||
|
"""Convert offset into boot ID if required."""
|
||||||
|
with suppress(CoerceInvalid):
|
||||||
|
offset = vol.Coerce(int)(possible_offset)
|
||||||
|
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
|
@api_process
|
||||||
def service_reload(self, request):
|
async def advanced_logs(
|
||||||
"""Reload a service."""
|
self, request: web.Request, identifier: str | None = None, follow: bool = False
|
||||||
unit = request.match_info.get(SERVICE)
|
) -> web.StreamResponse:
|
||||||
return asyncio.shield(self.sys_host.services.reload(unit))
|
"""Return systemd-journald logs."""
|
||||||
|
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
|
||||||
|
|
||||||
@api_process
|
if BOOTID in request.match_info:
|
||||||
def service_restart(self, request):
|
params[PARAM_BOOT_ID] = await self._get_boot_id(
|
||||||
"""Restart a service."""
|
request.match_info.get(BOOTID)
|
||||||
unit = request.match_info.get(SERVICE)
|
)
|
||||||
return asyncio.shield(self.sys_host.services.restart(unit))
|
if follow:
|
||||||
|
params[PARAM_FOLLOW] = ""
|
||||||
|
|
||||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
if ACCEPT in request.headers and request.headers[ACCEPT] not in [
|
||||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
CONTENT_TYPE_TEXT,
|
||||||
"""Return host kernel logs."""
|
"*/*",
|
||||||
return self.sys_host.info.get_dmesg()
|
]:
|
||||||
|
raise APIError(
|
||||||
|
"Invalid content type requested. Only text/plain supported for now."
|
||||||
|
)
|
||||||
|
|
||||||
|
if RANGE in request.headers:
|
||||||
|
range_header = request.headers.get(RANGE)
|
||||||
|
else:
|
||||||
|
range_header = f"entries=:-{DEFAULT_RANGE}:"
|
||||||
|
|
||||||
|
async with self.sys_host.logs.journald_logs(
|
||||||
|
params=params, range_header=range_header
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
response = web.StreamResponse()
|
||||||
|
response.content_type = CONTENT_TYPE_TEXT
|
||||||
|
await response.prepare(request)
|
||||||
|
async for data in resp.content:
|
||||||
|
await response.write(data)
|
||||||
|
except ConnectionResetError as ex:
|
||||||
|
raise APIError(
|
||||||
|
"Connection reset when trying to fetch data from systemd-journald."
|
||||||
|
) from ex
|
||||||
|
return response
|
||||||
|
|||||||
@@ -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.supported_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,
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,17 @@
|
|||||||
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,
|
||||||
HTTPUnauthorized,
|
HTTPUnauthorized,
|
||||||
)
|
)
|
||||||
from multidict import CIMultiDict, istr
|
from multidict import CIMultiDict, istr
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from ..addons.addon import Addon
|
from ..addons.addon import Addon
|
||||||
from ..const import (
|
from ..const import (
|
||||||
@@ -20,21 +21,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
|
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})
|
||||||
|
|
||||||
|
"""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")
|
||||||
@@ -47,17 +92,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:
|
||||||
@@ -71,18 +111,39 @@ 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
|
||||||
|
@require_home_assistant
|
||||||
|
async def validate_session(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Validate session and extending how long it's valid for."""
|
||||||
|
data = await api_validate(VALIDATE_SESSION_DATA, request)
|
||||||
|
|
||||||
|
# Check Ingress Session
|
||||||
|
if not self.sys_ingress.validate_session(data[ATTR_SESSION]):
|
||||||
|
_LOGGER.warning("No valid ingress session %s", data[ATTR_SESSION])
|
||||||
|
raise HTTPUnauthorized()
|
||||||
|
|
||||||
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)
|
||||||
@@ -93,13 +154,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)
|
||||||
@@ -107,7 +169,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:
|
||||||
@@ -125,7 +191,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:
|
||||||
@@ -142,8 +208,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,
|
||||||
)
|
)
|
||||||
@@ -151,12 +217,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,
|
||||||
@@ -165,12 +244,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
|
||||||
@@ -178,13 +267,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)
|
||||||
@@ -200,24 +289,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
|
||||||
@@ -230,7 +345,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 = {}
|
||||||
|
|
||||||
|
|||||||
80
supervisor/api/jobs.py
Normal file
80
supervisor/api/jobs.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Init file for Supervisor Jobs RESTful API."""
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from ..coresys import CoreSysAttributes
|
||||||
|
from ..jobs import SupervisorJob
|
||||||
|
from ..jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition
|
||||||
|
from .const import ATTR_JOBS
|
||||||
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SCHEMA_OPTIONS = vol.Schema(
|
||||||
|
{vol.Optional(ATTR_IGNORE_CONDITIONS): [vol.Coerce(JobCondition)]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class APIJobs(CoreSysAttributes):
|
||||||
|
"""Handle RESTful API for OS functions."""
|
||||||
|
|
||||||
|
def _list_jobs(self) -> 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, 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
|
||||||
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Return JobManager information."""
|
||||||
|
return {
|
||||||
|
ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions,
|
||||||
|
ATTR_JOBS: self._list_jobs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def options(self, request: web.Request) -> None:
|
||||||
|
"""Set options for JobManager."""
|
||||||
|
body = await api_validate(SCHEMA_OPTIONS, request)
|
||||||
|
|
||||||
|
if ATTR_IGNORE_CONDITIONS in body:
|
||||||
|
self.sys_jobs.ignore_conditions = body[ATTR_IGNORE_CONDITIONS]
|
||||||
|
|
||||||
|
self.sys_jobs.save_data()
|
||||||
|
|
||||||
|
await self.sys_resolution.evaluate.evaluate_system()
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def reset(self, request: web.Request) -> None:
|
||||||
|
"""Reset options for JobManager."""
|
||||||
|
self.sys_jobs.reset_data()
|
||||||
1
supervisor/api/middleware/__init__.py
Normal file
1
supervisor/api/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API middleware for aiohttp."""
|
||||||
313
supervisor/api/middleware/security.py
Normal file
313
supervisor/api/middleware/security.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
"""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 ...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")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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"|/cli/.+"
|
||||||
|
r"|/core/.+"
|
||||||
|
r"|/dns/.+"
|
||||||
|
r"|/docker/.+"
|
||||||
|
r"|/jobs/.+"
|
||||||
|
r"|/hardware/.+"
|
||||||
|
r"|/hassos/.+"
|
||||||
|
r"|/homeassistant/.+"
|
||||||
|
r"|/host/.+"
|
||||||
|
r"|/multicast/.+"
|
||||||
|
r"|/network/.+"
|
||||||
|
r"|/observer/.+"
|
||||||
|
r"|/os/.+"
|
||||||
|
r"|/resolution/.+"
|
||||||
|
r"|/backups.*"
|
||||||
|
r"|/snapshots.*"
|
||||||
|
r"|/store.*"
|
||||||
|
r"|/supervisor/.+"
|
||||||
|
r"|/security/.+"
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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 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,8 @@
|
|||||||
"""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
|
||||||
@@ -15,13 +16,14 @@ from ..const import (
|
|||||||
ATTR_MEMORY_USAGE,
|
ATTR_MEMORY_USAGE,
|
||||||
ATTR_NETWORK_RX,
|
ATTR_NETWORK_RX,
|
||||||
ATTR_NETWORK_TX,
|
ATTR_NETWORK_TX,
|
||||||
|
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 .const import CONTENT_TYPE_BINARY
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .utils import api_process, api_process_raw, api_validate
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
@@ -33,15 +35,16 @@ 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,
|
||||||
ATTR_VERSION_LATEST: self.sys_plugins.multicast.latest_version,
|
ATTR_VERSION_LATEST: self.sys_plugins.multicast.latest_version,
|
||||||
|
ATTR_UPDATE_AVAILABLE: self.sys_plugins.multicast.need_update,
|
||||||
}
|
}
|
||||||
|
|
||||||
@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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +1,292 @@
|
|||||||
"""REST API for network."""
|
"""REST API for network."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any, Dict
|
from collections.abc import Awaitable
|
||||||
|
from dataclasses import replace
|
||||||
|
from ipaddress import ip_address, ip_interface
|
||||||
|
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_ACCESSPOINTS,
|
||||||
ATTR_ADDRESS,
|
ATTR_ADDRESS,
|
||||||
|
ATTR_AUTH,
|
||||||
|
ATTR_CONNECTED,
|
||||||
ATTR_DNS,
|
ATTR_DNS,
|
||||||
|
ATTR_DOCKER,
|
||||||
|
ATTR_ENABLED,
|
||||||
|
ATTR_FREQUENCY,
|
||||||
ATTR_GATEWAY,
|
ATTR_GATEWAY,
|
||||||
|
ATTR_HOST_INTERNET,
|
||||||
ATTR_ID,
|
ATTR_ID,
|
||||||
ATTR_INTERFACE,
|
ATTR_INTERFACE,
|
||||||
ATTR_INTERFACES,
|
ATTR_INTERFACES,
|
||||||
ATTR_IP_ADDRESS,
|
ATTR_IPV4,
|
||||||
|
ATTR_IPV6,
|
||||||
|
ATTR_MAC,
|
||||||
ATTR_METHOD,
|
ATTR_METHOD,
|
||||||
ATTR_METHODS,
|
ATTR_MODE,
|
||||||
ATTR_NAMESERVERS,
|
ATTR_NAMESERVERS,
|
||||||
|
ATTR_PARENT,
|
||||||
ATTR_PRIMARY,
|
ATTR_PRIMARY,
|
||||||
|
ATTR_PSK,
|
||||||
|
ATTR_READY,
|
||||||
|
ATTR_SIGNAL,
|
||||||
|
ATTR_SSID,
|
||||||
|
ATTR_SUPERVISOR_INTERNET,
|
||||||
ATTR_TYPE,
|
ATTR_TYPE,
|
||||||
|
ATTR_VLAN,
|
||||||
|
ATTR_WIFI,
|
||||||
|
DOCKER_NETWORK,
|
||||||
|
DOCKER_NETWORK_MASK,
|
||||||
)
|
)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..dbus.const import InterfaceMethodSimple
|
from ..exceptions import APIError, HostNetworkNotFound
|
||||||
from ..dbus.network.interface import NetworkInterface
|
from ..host.configuration import (
|
||||||
from ..dbus.network.utils import int2ip
|
AccessPoint,
|
||||||
from ..exceptions import APIError
|
Interface,
|
||||||
|
InterfaceMethod,
|
||||||
|
IpConfig,
|
||||||
|
VlanConfig,
|
||||||
|
WifiConfig,
|
||||||
|
)
|
||||||
|
from ..host.const import AuthMethod, InterfaceType, WifiMode
|
||||||
from .utils import api_process, api_validate
|
from .utils import api_process, api_validate
|
||||||
|
|
||||||
SCHEMA_UPDATE = vol.Schema(
|
_SCHEMA_IP_CONFIG = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_ADDRESS): vol.Coerce(str),
|
vol.Optional(ATTR_ADDRESS): [vol.Coerce(ip_interface)],
|
||||||
vol.Optional(ATTR_METHOD): vol.In(ATTR_METHODS),
|
vol.Optional(ATTR_METHOD): vol.Coerce(InterfaceMethod),
|
||||||
vol.Optional(ATTR_GATEWAY): vol.Coerce(str),
|
vol.Optional(ATTR_GATEWAY): vol.Coerce(ip_address),
|
||||||
vol.Optional(ATTR_DNS): [str],
|
vol.Optional(ATTR_NAMESERVERS): [vol.Coerce(ip_address)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_SCHEMA_WIFI_CONFIG = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_MODE): vol.Coerce(WifiMode),
|
||||||
|
vol.Optional(ATTR_AUTH): vol.Coerce(AuthMethod),
|
||||||
|
vol.Optional(ATTR_SSID): str,
|
||||||
|
vol.Optional(ATTR_PSK): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def interface_information(interface: NetworkInterface) -> dict:
|
# pylint: disable=no-value-for-parameter
|
||||||
|
SCHEMA_UPDATE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(ATTR_IPV4): _SCHEMA_IP_CONFIG,
|
||||||
|
vol.Optional(ATTR_IPV6): _SCHEMA_IP_CONFIG,
|
||||||
|
vol.Optional(ATTR_WIFI): _SCHEMA_WIFI_CONFIG,
|
||||||
|
vol.Optional(ATTR_ENABLED): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ipconfig_struct(config: IpConfig) -> dict[str, Any]:
|
||||||
|
"""Return a dict with information about ip configuration."""
|
||||||
|
return {
|
||||||
|
ATTR_METHOD: config.method,
|
||||||
|
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
|
||||||
|
ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
|
||||||
|
ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
|
||||||
|
ATTR_READY: config.ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def wifi_struct(config: WifiConfig) -> dict[str, Any]:
|
||||||
|
"""Return a dict with information about wifi configuration."""
|
||||||
|
return {
|
||||||
|
ATTR_MODE: config.mode,
|
||||||
|
ATTR_AUTH: config.auth,
|
||||||
|
ATTR_SSID: config.ssid,
|
||||||
|
ATTR_SIGNAL: config.signal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
ATTR_IP_ADDRESS: f"{interface.ip_address}/{interface.prefix}",
|
|
||||||
ATTR_GATEWAY: interface.gateway,
|
|
||||||
ATTR_ID: interface.id,
|
|
||||||
ATTR_TYPE: interface.type,
|
ATTR_TYPE: interface.type,
|
||||||
ATTR_NAMESERVERS: [int2ip(x) for x in interface.nameservers],
|
ATTR_ENABLED: interface.enabled,
|
||||||
ATTR_METHOD: InterfaceMethodSimple.DHCP
|
ATTR_CONNECTED: interface.connected,
|
||||||
if interface.method == "auto"
|
|
||||||
else InterfaceMethodSimple.STATIC,
|
|
||||||
ATTR_PRIMARY: interface.primary,
|
ATTR_PRIMARY: interface.primary,
|
||||||
|
ATTR_MAC: interface.mac,
|
||||||
|
ATTR_IPV4: ipconfig_struct(interface.ipv4) if interface.ipv4 else None,
|
||||||
|
ATTR_IPV6: ipconfig_struct(interface.ipv6) if interface.ipv6 else None,
|
||||||
|
ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None,
|
||||||
|
ATTR_VLAN: vlan_struct(interface.vlan) if interface.vlan else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def accesspoint_struct(accesspoint: AccessPoint) -> dict[str, Any]:
|
||||||
|
"""Return a dict for AccessPoint."""
|
||||||
|
return {
|
||||||
|
ATTR_MODE: accesspoint.mode,
|
||||||
|
ATTR_SSID: accesspoint.ssid,
|
||||||
|
ATTR_FREQUENCY: accesspoint.frequency,
|
||||||
|
ATTR_SIGNAL: accesspoint.signal,
|
||||||
|
ATTR_MAC: accesspoint.mac,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class APINetwork(CoreSysAttributes):
|
class APINetwork(CoreSysAttributes):
|
||||||
"""Handle REST API for network."""
|
"""Handle REST API for network."""
|
||||||
|
|
||||||
@api_process
|
def _get_interface(self, name: str) -> Interface:
|
||||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
"""Get Interface by name or default."""
|
||||||
"""Return network information."""
|
if name.lower() == "default":
|
||||||
interfaces = {}
|
|
||||||
for interface in self.sys_host.network.interfaces:
|
|
||||||
interfaces[
|
|
||||||
self.sys_host.network.interfaces[interface].name
|
|
||||||
] = interface_information(self.sys_host.network.interfaces[interface])
|
|
||||||
|
|
||||||
return {ATTR_INTERFACES: interfaces}
|
|
||||||
|
|
||||||
@api_process
|
|
||||||
async def interface_info(self, request: web.Request) -> Dict[str, Any]:
|
|
||||||
"""Return network information for a interface."""
|
|
||||||
req_interface = request.match_info.get(ATTR_INTERFACE)
|
|
||||||
|
|
||||||
if req_interface.lower() == "default":
|
|
||||||
for interface in self.sys_host.network.interfaces:
|
for interface in self.sys_host.network.interfaces:
|
||||||
if not self.sys_host.network.interfaces[interface].primary:
|
if not interface.primary:
|
||||||
continue
|
continue
|
||||||
return interface_information(
|
return interface
|
||||||
self.sys_host.network.interfaces[interface]
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
for interface in self.sys_host.network.interfaces:
|
try:
|
||||||
if req_interface != self.sys_host.network.interfaces[interface].name:
|
return self.sys_host.network.get(name)
|
||||||
continue
|
except HostNetworkNotFound:
|
||||||
return interface_information(
|
pass
|
||||||
self.sys_host.network.interfaces[interface]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {}
|
raise APIError(f"Interface {name} does not exist") from None
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def interface_update(self, request: web.Request) -> Dict[str, Any]:
|
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Return network information."""
|
||||||
|
return {
|
||||||
|
ATTR_INTERFACES: [
|
||||||
|
interface_struct(interface)
|
||||||
|
for interface in self.sys_host.network.interfaces
|
||||||
|
],
|
||||||
|
ATTR_DOCKER: {
|
||||||
|
ATTR_INTERFACE: DOCKER_NETWORK,
|
||||||
|
ATTR_ADDRESS: str(DOCKER_NETWORK_MASK),
|
||||||
|
ATTR_GATEWAY: str(self.sys_docker.network.gateway),
|
||||||
|
ATTR_DNS: str(self.sys_docker.network.dns),
|
||||||
|
},
|
||||||
|
ATTR_HOST_INTERNET: self.sys_host.network.connectivity,
|
||||||
|
ATTR_SUPERVISOR_INTERNET: self.sys_supervisor.connectivity,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def interface_info(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Return network information for a interface."""
|
||||||
|
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
||||||
|
|
||||||
|
return interface_struct(interface)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def interface_update(self, request: web.Request) -> None:
|
||||||
"""Update the configuration of an interface."""
|
"""Update the configuration of an interface."""
|
||||||
req_interface = request.match_info.get(ATTR_INTERFACE)
|
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
||||||
|
|
||||||
if not self.sys_host.network.interfaces.get(req_interface):
|
# Validate data
|
||||||
raise APIError(f"Interface {req_interface} does not exsist")
|
body = await api_validate(SCHEMA_UPDATE, request)
|
||||||
|
if not body:
|
||||||
args = await api_validate(SCHEMA_UPDATE, request)
|
|
||||||
if not args:
|
|
||||||
raise APIError("You need to supply at least one option to update")
|
raise APIError("You need to supply at least one option to update")
|
||||||
|
|
||||||
await asyncio.shield(
|
# Apply config
|
||||||
self.sys_host.network.interfaces[req_interface].update_settings(**args)
|
for key, config in body.items():
|
||||||
|
if key == ATTR_IPV4:
|
||||||
|
interface.ipv4 = replace(
|
||||||
|
interface.ipv4
|
||||||
|
or IpConfig(InterfaceMethod.STATIC, [], None, [], None),
|
||||||
|
**config,
|
||||||
|
)
|
||||||
|
elif key == ATTR_IPV6:
|
||||||
|
interface.ipv6 = replace(
|
||||||
|
interface.ipv6
|
||||||
|
or IpConfig(InterfaceMethod.STATIC, [], None, [], None),
|
||||||
|
**config,
|
||||||
|
)
|
||||||
|
elif key == ATTR_WIFI:
|
||||||
|
interface.wifi = replace(
|
||||||
|
interface.wifi
|
||||||
|
or WifiConfig(
|
||||||
|
WifiMode.INFRASTRUCTURE, "", AuthMethod.OPEN, None, None
|
||||||
|
),
|
||||||
|
**config,
|
||||||
|
)
|
||||||
|
elif key == ATTR_ENABLED:
|
||||||
|
interface.enabled = config
|
||||||
|
|
||||||
|
await asyncio.shield(self.sys_host.network.apply_changes(interface))
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
def reload(self, request: web.Request) -> Awaitable[None]:
|
||||||
|
"""Reload network data."""
|
||||||
|
return asyncio.shield(
|
||||||
|
self.sys_host.network.update(force_connectivity_check=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.shield(self.sys_host.reload())
|
@api_process
|
||||||
|
async def scan_accesspoints(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Scan and return a list of available networks."""
|
||||||
|
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
||||||
|
|
||||||
return await asyncio.shield(self.interface_info(request))
|
# Only wlan is supported
|
||||||
|
if interface.type != InterfaceType.WIRELESS:
|
||||||
|
raise APIError(f"Interface {interface.name} is not a valid wireless card!")
|
||||||
|
|
||||||
|
ap_list = await self.sys_host.network.scan_wifi(interface)
|
||||||
|
|
||||||
|
return {ATTR_ACCESSPOINTS: [accesspoint_struct(ap) for ap in ap_list]}
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def create_vlan(self, request: web.Request) -> None:
|
||||||
|
"""Create a new vlan."""
|
||||||
|
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
||||||
|
vlan = int(request.match_info.get(ATTR_VLAN))
|
||||||
|
|
||||||
|
# Only ethernet is supported
|
||||||
|
if interface.type != InterfaceType.ETHERNET:
|
||||||
|
raise APIError(
|
||||||
|
f"Interface {interface.name} is not a valid ethernet card for vlan!"
|
||||||
|
)
|
||||||
|
body = await api_validate(SCHEMA_UPDATE, request)
|
||||||
|
|
||||||
|
vlan_config = VlanConfig(vlan, interface.name)
|
||||||
|
|
||||||
|
ipv4_config = None
|
||||||
|
if ATTR_IPV4 in body:
|
||||||
|
ipv4_config = IpConfig(
|
||||||
|
body[ATTR_IPV4].get(ATTR_METHOD, InterfaceMethod.AUTO),
|
||||||
|
body[ATTR_IPV4].get(ATTR_ADDRESS, []),
|
||||||
|
body[ATTR_IPV4].get(ATTR_GATEWAY, None),
|
||||||
|
body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
ipv6_config = None
|
||||||
|
if ATTR_IPV6 in body:
|
||||||
|
ipv6_config = IpConfig(
|
||||||
|
body[ATTR_IPV6].get(ATTR_METHOD, InterfaceMethod.AUTO),
|
||||||
|
body[ATTR_IPV6].get(ATTR_ADDRESS, []),
|
||||||
|
body[ATTR_IPV6].get(ATTR_GATEWAY, None),
|
||||||
|
body[ATTR_IPV6].get(ATTR_NAMESERVERS, []),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
vlan_interface = Interface(
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
InterfaceType.VLAN,
|
||||||
|
ipv4_config,
|
||||||
|
ipv6_config,
|
||||||
|
None,
|
||||||
|
vlan_config,
|
||||||
|
)
|
||||||
|
await asyncio.shield(self.sys_host.network.apply_changes(vlan_interface))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""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
|
||||||
@@ -16,6 +16,7 @@ from ..const import (
|
|||||||
ATTR_MEMORY_USAGE,
|
ATTR_MEMORY_USAGE,
|
||||||
ATTR_NETWORK_RX,
|
ATTR_NETWORK_RX,
|
||||||
ATTR_NETWORK_TX,
|
ATTR_NETWORK_TX,
|
||||||
|
ATTR_UPDATE_AVAILABLE,
|
||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
ATTR_VERSION_LATEST,
|
ATTR_VERSION_LATEST,
|
||||||
)
|
)
|
||||||
@@ -32,16 +33,17 @@ 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),
|
||||||
ATTR_VERSION: self.sys_plugins.observer.version,
|
ATTR_VERSION: self.sys_plugins.observer.version,
|
||||||
ATTR_VERSION_LATEST: self.sys_plugins.observer.latest_version,
|
ATTR_VERSION_LATEST: self.sys_plugins.observer.latest_version,
|
||||||
|
ATTR_UPDATE_AVAILABLE: self.sys_plugins.observer.need_update,
|
||||||
}
|
}
|
||||||
|
|
||||||
@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,43 +1,181 @@
|
|||||||
"""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 ATTR_BOARD, ATTR_BOOT, ATTR_VERSION, ATTR_VERSION_LATEST
|
from ..const import (
|
||||||
|
ATTR_ACTIVITY_LED,
|
||||||
|
ATTR_BOARD,
|
||||||
|
ATTR_BOOT,
|
||||||
|
ATTR_DEVICES,
|
||||||
|
ATTR_DISK_LED,
|
||||||
|
ATTR_HEARTBEAT_LED,
|
||||||
|
ATTR_ID,
|
||||||
|
ATTR_NAME,
|
||||||
|
ATTR_POWER_LED,
|
||||||
|
ATTR_SERIAL,
|
||||||
|
ATTR_SIZE,
|
||||||
|
ATTR_UPDATE_AVAILABLE,
|
||||||
|
ATTR_VERSION,
|
||||||
|
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_DATA_DISK,
|
||||||
|
ATTR_DEV_PATH,
|
||||||
|
ATTR_DEVICE,
|
||||||
|
ATTR_DISKS,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_SYSTEM_HEALTH_LED,
|
||||||
|
ATTR_VENDOR,
|
||||||
|
)
|
||||||
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_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_BOARD: self.sys_hassos.board,
|
ATTR_UPDATE_AVAILABLE: self.sys_os.need_update,
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
||||||
|
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:
|
||||||
|
self.sys_dbus.agent.board.green.activity_led = body[ATTR_ACTIVITY_LED]
|
||||||
|
|
||||||
|
if ATTR_POWER_LED in body:
|
||||||
|
self.sys_dbus.agent.board.green.power_led = body[ATTR_POWER_LED]
|
||||||
|
|
||||||
|
if ATTR_SYSTEM_HEALTH_LED in body:
|
||||||
|
self.sys_dbus.agent.board.green.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:
|
||||||
|
self.sys_dbus.agent.board.yellow.disk_led = body[ATTR_DISK_LED]
|
||||||
|
|
||||||
|
if ATTR_HEARTBEAT_LED in body:
|
||||||
|
self.sys_dbus.agent.board.yellow.heartbeat_led = body[ATTR_HEARTBEAT_LED]
|
||||||
|
|
||||||
|
if ATTR_POWER_LED in body:
|
||||||
|
self.sys_dbus.agent.board.yellow.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.f7e7035c.js')")();
|
|
||||||
} catch (err) {
|
|
||||||
var el = document.createElement('script');
|
|
||||||
el.src = '/api/hassio/app/frontend_es5/entrypoint.c862ef13.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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user