mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-12 18:49:20 +00:00
Compare commits
726 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 |
@@ -1,60 +0,0 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.8
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
WORKDIR /workspaces
|
||||
|
||||
# Set Docker daemon config
|
||||
RUN \
|
||||
mkdir -p /etc/docker \
|
||||
&& echo '{"storage-driver": "vfs"}' > /etc/docker/daemon.json
|
||||
|
||||
# Install Node/Yarn for Frontent
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
|
||||
&& apt-get update \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
git \
|
||||
apt-utils \
|
||||
apt-transport-https \
|
||||
&& 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 \
|
||||
&& bash <(curl https://getvcn.codenotary.com -L) \
|
||||
&& 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,19 +1,23 @@
|
||||
{
|
||||
"name": "Supervisor dev",
|
||||
"context": "..",
|
||||
"dockerFile": "Dockerfile",
|
||||
"appPort": "9123:8123",
|
||||
"postCreateCommand": "pre-commit install",
|
||||
"image": "ghcr.io/home-assistant/devcontainer:supervisor",
|
||||
"appPort": ["9123:8123", "7357:4357"],
|
||||
"postCreateCommand": "bash devcontainer_bootstrap",
|
||||
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
||||
"containerEnv": {"NVM_DIR":"/usr/local/share/nvm"},
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"visualstudioexptteam.vscodeintellicode",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"mounts": ["type=volume,target=/var/lib/docker"],
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"zsh": {
|
||||
"path": "/usr/bin/zsh"
|
||||
}
|
||||
},
|
||||
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
@@ -22,7 +26,7 @@
|
||||
"python.linting.pylintEnabled": true,
|
||||
"python.linting.enabled": true,
|
||||
"python.formatting.provider": "black",
|
||||
"python.formatting.blackArgs": ["--target-version", "py38"],
|
||||
"python.formatting.blackArgs": ["--target-version", "py310"],
|
||||
"python.formatting.blackPath": "/usr/local/bin/black",
|
||||
"python.linting.banditPath": "/usr/local/bin/bandit",
|
||||
"python.linting.flake8Path": "/usr/local/bin/flake8",
|
||||
|
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,8 +1,6 @@
|
||||
name: Bug Report Form
|
||||
about: Report an issue related to the Home Assistant Supervisor.
|
||||
description: Report an issue related to the Home Assistant Supervisor.
|
||||
labels: bug
|
||||
title: ""
|
||||
issue_body: true
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -22,22 +20,14 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
## Environment
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: What is the used version of the Supervisor?
|
||||
placeholder: supervisor-
|
||||
description: >
|
||||
Can be found in the Supervisor panel -> System tab. Starts with
|
||||
`supervisor-....`.
|
||||
- type: dropdown
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: What type of installation are you running?
|
||||
description: >
|
||||
If you don't know, you can find it in: Configuration panel -> Info.
|
||||
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
|
||||
@@ -50,22 +40,6 @@ body:
|
||||
- Home Assistant Operating System
|
||||
- Debian
|
||||
- Other (e.g., Raspbian/Raspberry Pi OS/Fedora)
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: What is the version of your installed operating system?
|
||||
placeholder: "5.11"
|
||||
description: Can be found in the Supervisor panel -> System tab.
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: What version of Home Assistant Core is installed?
|
||||
placeholder: core-
|
||||
description: >
|
||||
Can be found in the Supervisor panel -> System tab. Starts with
|
||||
`core-....`.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
@@ -89,18 +63,34 @@ body:
|
||||
attributes:
|
||||
label: Anything in the Supervisor logs that might be useful for us?
|
||||
description: >
|
||||
The Supervisor logs can be found in the Supervisor panel -> System tab.
|
||||
value: |
|
||||
```txt
|
||||
# Put your logs below this line
|
||||
Supervisor Logs can be found in [Settings -> System -> Logs](https://my.home-assistant.io/redirect/logs/)
|
||||
then choose `Supervisor` in the top right.
|
||||
|
||||
```
|
||||
- type: markdown
|
||||
[](https://my.home-assistant.io/redirect/supervisor_logs/)
|
||||
render: txt
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
value: |
|
||||
## Additional information
|
||||
- type: markdown
|
||||
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:
|
||||
value: |
|
||||
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.
|
||||
Please note, you can attach screenshots or screen recordings here, by
|
||||
dragging and dropping files in the field below.
|
||||
|
1
.github/release-drafter.yml
vendored
1
.github/release-drafter.yml
vendored
@@ -31,6 +31,7 @@ categories:
|
||||
|
||||
- title: ":arrow_up: Dependency Updates"
|
||||
label: "dependencies"
|
||||
collapse-after: 1
|
||||
|
||||
include-labels:
|
||||
- "breaking-change"
|
||||
|
219
.github/workflows/builder.yml
vendored
219
.github/workflows/builder.yml
vendored
@@ -27,15 +27,19 @@ on:
|
||||
paths:
|
||||
- "rootfs/**"
|
||||
- "supervisor/**"
|
||||
- build.json
|
||||
- build.yaml
|
||||
- Dockerfile
|
||||
- requirements.txt
|
||||
- setup.py
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.10"
|
||||
BUILD_NAME: supervisor
|
||||
BUILD_TYPE: supervisor
|
||||
WHEELS_TAG: 3.8-alpine3.13
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
init:
|
||||
@@ -49,7 +53,7 @@ jobs:
|
||||
requirements: ${{ steps.requirements.outputs.changed }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -71,7 +75,7 @@ jobs:
|
||||
- name: Check if requirements files changed
|
||||
id: requirements
|
||||
run: |
|
||||
if [[ "${{ steps.changed_files.outputs.all }}" =~ requirements.txt ]]; then
|
||||
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.json) ]]; then
|
||||
echo "::set-output name=changed::true"
|
||||
fi
|
||||
|
||||
@@ -84,21 +88,29 @@ jobs:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
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@master
|
||||
uses: home-assistant/wheels@2022.10.1
|
||||
with:
|
||||
tag: ${{ env.WHEELS_TAG }}
|
||||
abi: cp310
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-host: ${{ secrets.WHEELS_HOST }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
wheels-user: wheels
|
||||
apk: "build-base;libffi-dev;openssl-dev;cargo"
|
||||
apk: "libffi-dev;openssl-dev"
|
||||
skip-binary: aiohttp
|
||||
env-file: true
|
||||
requirements: "requirements.txt"
|
||||
|
||||
- name: Set version
|
||||
@@ -109,60 +121,72 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GIT_USER }}
|
||||
password: ${{ secrets.GIT_TOKEN }}
|
||||
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@2021.04.0
|
||||
uses: home-assistant/builder@2022.09.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--${{ matrix.arch }} \
|
||||
--target /data \
|
||||
--with-codenotary "${{ secrets.VCN_USER }}" "${{ secrets.VCN_PASSWORD }}" "${{ secrets.VCN_ORG }}" \
|
||||
--validate-from "${{ secrets.VCN_ORG }}" \
|
||||
--validate-cache "${{ secrets.VCN_ORG }}" \
|
||||
--generic ${{ needs.init.outputs.version }}
|
||||
env:
|
||||
CAS_API_KEY: ${{ secrets.CAS_TOKEN }}
|
||||
|
||||
codenotary:
|
||||
name: CodeNotary signature
|
||||
name: CAS signature
|
||||
needs: init
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/setup-python@v4.3.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Set version
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: home-assistant/actions/helpers/version@master
|
||||
with:
|
||||
type: ${{ env.BUILD_TYPE }}
|
||||
|
||||
- name: Signing image
|
||||
- name: Install dirhash and calc hash
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
id: dirhash
|
||||
run: |
|
||||
pip3 install dirhash
|
||||
dir_hash="$(dirhash "${{ github.workspace }}/supervisor" -a sha256 --match "*.py")"
|
||||
echo "::set-output name=dirhash::${dir_hash}"
|
||||
|
||||
- name: Signing Source
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: home-assistant/actions/helpers/codenotary@master
|
||||
with:
|
||||
source: dir://${{ github.workspace }}
|
||||
user: ${{ secrets.VCN_USER }}
|
||||
password: ${{ secrets.VCN_PASSWORD }}
|
||||
organisation: ${{ secrets.VCN_ORG }}
|
||||
source: hash://${{ steps.dirhash.outputs.dirhash }}
|
||||
asset: supervisor-${{ needs.init.outputs.version }}
|
||||
token: ${{ secrets.CAS_TOKEN }}
|
||||
|
||||
version:
|
||||
name: Update version
|
||||
@@ -171,7 +195,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
|
||||
- name: Initialize git
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
@@ -192,13 +216,15 @@ jobs:
|
||||
run_supervisor:
|
||||
runs-on: ubuntu-latest
|
||||
name: Run the Supervisor
|
||||
needs: ["build", "codenotary"]
|
||||
needs: ["build", "codenotary", "init"]
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
|
||||
- name: Build the Supervisor
|
||||
uses: home-assistant/builder@2021.04.0
|
||||
if: needs.init.outputs.publish != 'true'
|
||||
uses: home-assistant/builder@2022.09.0
|
||||
with:
|
||||
args: |
|
||||
--test \
|
||||
@@ -206,13 +232,19 @@ jobs:
|
||||
--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 }} homeassistant/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 \
|
||||
--security-opt apparmor=unconfined \
|
||||
-v /run/docker.sock:/run/docker.sock \
|
||||
-v /run/dbus:/run/dbus \
|
||||
-v /tmp/supervisor/data:/data \
|
||||
@@ -231,22 +263,135 @@ jobs:
|
||||
SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.IPAddress}}' hassio_supervisor)
|
||||
ping="error"
|
||||
while [ "$ping" != "ok" ]; do
|
||||
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r .result)
|
||||
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r '.result')
|
||||
sleep 5
|
||||
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
|
||||
docker logs hassio_supervisor
|
||||
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
|
||||
docker logs hassio_supervisor
|
||||
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 "::set-output name=slug::$(echo $test | jq -r '.data.slug')"
|
||||
|
||||
- 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
|
||||
|
168
.github/workflows/ci.yaml
vendored
168
.github/workflows/ci.yaml
vendored
@@ -8,36 +8,37 @@ on:
|
||||
pull_request: ~
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: 3.8
|
||||
DEFAULT_PYTHON: "3.10"
|
||||
PRE_COMMIT_HOME: ~/.cache/pre-commit
|
||||
DEFAULT_CAS: v1.0.2
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Separate job to pre-populate the base dependency cache
|
||||
# This prevent upcoming jobs to do the same individually
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
name: Prepare Python ${{ matrix.python-version }} dependencies
|
||||
outputs:
|
||||
python-version: ${{ steps.python.outputs.python-version }}
|
||||
name: Prepare Python dependencies
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python
|
||||
id: python
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/setup-python@v4.3.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
key: |
|
||||
${{ 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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -47,7 +48,7 @@ jobs:
|
||||
pip install -r requirements.txt -r requirements_tests.txt
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -66,19 +67,19 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -95,7 +96,7 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Register hadolint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
|
||||
@@ -110,19 +111,19 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -130,7 +131,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -154,19 +155,19 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -186,19 +187,19 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -206,7 +207,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -227,19 +228,19 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -247,7 +248,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -271,19 +272,19 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -303,19 +304,19 @@ jobs:
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -323,7 +324,7 @@ jobs:
|
||||
exit 1
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: ${{ env.PRE_COMMIT_HOME }}
|
||||
key: |
|
||||
@@ -341,29 +342,26 @@ jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
needs: prepare
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
name: Run tests Python ${{ matrix.python-version }}
|
||||
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install CodeNotary
|
||||
shell: bash
|
||||
run: |
|
||||
bash <(curl https://getvcn.codenotary.com -L)
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Install CAS tools
|
||||
uses: home-assistant/actions/helpers/cas@master
|
||||
with:
|
||||
version: ${{ env.DEFAULT_CAS }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -394,7 +392,7 @@ jobs:
|
||||
-o console_output_style=count \
|
||||
tests
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v2.2.3
|
||||
uses: actions/upload-artifact@v3.1.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
path: .coverage
|
||||
@@ -402,29 +400,29 @@ jobs:
|
||||
coverage:
|
||||
name: Process test coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: pytest
|
||||
needs: ["pytest", "prepare"]
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.2.2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
|
||||
uses: actions/setup-python@v4.3.0
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
python-version: ${{ needs.prepare.outputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v2.1.5
|
||||
uses: actions/cache@v3.0.11
|
||||
with:
|
||||
path: venv
|
||||
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
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
echo "Failed to restore Python virtual environment from cache"
|
||||
exit 1
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v3
|
||||
- name: Combine coverage results
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -432,4 +430,4 @@ jobs:
|
||||
coverage report
|
||||
coverage xml
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v1.3.2
|
||||
uses: codecov/codecov-action@v3.1.1
|
||||
|
10
.github/workflows/lock.yml
vendored
10
.github/workflows/lock.yml
vendored
@@ -9,12 +9,12 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v2.0.3
|
||||
- uses: dessant/lock-threads@v3.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-lock-inactive-days: "30"
|
||||
issue-exclude-created-before: "2020-10-01T00:00:00Z"
|
||||
issue-inactive-days: "30"
|
||||
exclude-issue-created-before: "2020-10-01T00:00:00Z"
|
||||
issue-lock-reason: ""
|
||||
pr-lock-inactive-days: "1"
|
||||
pr-exclude-created-before: "2020-11-01T00:00:00Z"
|
||||
pr-inactive-days: "1"
|
||||
exclude-pr-created-before: "2020-11-01T00:00:00Z"
|
||||
pr-lock-reason: ""
|
||||
|
4
.github/workflows/release-drafter.yml
vendored
4
.github/workflows/release-drafter.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Release Drafter
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
echo "::set-output name=version::$datepre.$newpost"
|
||||
|
||||
- name: Run Release Drafter
|
||||
uses: release-drafter/release-drafter@v5
|
||||
uses: release-drafter/release-drafter@v5.21.1
|
||||
with:
|
||||
tag: ${{ steps.version.outputs.version }}
|
||||
name: ${{ steps.version.outputs.version }}
|
||||
|
4
.github/workflows/sentry.yaml
vendored
4
.github/workflows/sentry.yaml
vendored
@@ -10,9 +10,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3.1.0
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.1
|
||||
uses: getsentry/action-release@v1.2.1
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
|
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -9,10 +9,10 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v3.0.18
|
||||
- uses: actions/stale@v6.0.1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
days-before-stale: 30
|
||||
days-before-close: 7
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"
|
||||
|
@@ -1,13 +1,13 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 20.8b1
|
||||
rev: 22.6.0
|
||||
hooks:
|
||||
- id: black
|
||||
args:
|
||||
- --safe
|
||||
- --quiet
|
||||
- --target-version
|
||||
- py38
|
||||
- py310
|
||||
files: ^((supervisor|tests)/.+)?[^/]+\.py$
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.8.3
|
||||
@@ -23,12 +23,12 @@ repos:
|
||||
- id: check-executables-have-shebangs
|
||||
stages: [manual]
|
||||
- id: check-json
|
||||
- repo: https://github.com/pre-commit/mirrors-isort
|
||||
rev: v4.3.21
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.9.3
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.6.2
|
||||
rev: v3.2.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
args: [--py310-plus]
|
||||
|
25
.vscode/tasks.json
vendored
25
.vscode/tasks.json
vendored
@@ -4,7 +4,7 @@
|
||||
{
|
||||
"label": "Run Supervisor",
|
||||
"type": "shell",
|
||||
"command": "./scripts/run-supervisor.sh",
|
||||
"command": "supervisor_run",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
@@ -15,20 +15,6 @@
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Build Supervisor",
|
||||
"type": "shell",
|
||||
"command": "./scripts/build-supervisor.sh",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Run Supervisor CLI",
|
||||
"type": "shell",
|
||||
@@ -46,7 +32,7 @@
|
||||
{
|
||||
"label": "Update Supervisor Panel",
|
||||
"type": "shell",
|
||||
"command": "./scripts/update-frontend.sh",
|
||||
"command": "LOKALISE_TOKEN='${input:localiseToken}' ./scripts/update-frontend.sh",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
@@ -100,5 +86,12 @@
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "localiseToken",
|
||||
"type": "promptString",
|
||||
"description": "Paste your lokalise token to download frontend translations"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
40
Dockerfile
40
Dockerfile
@@ -5,18 +5,17 @@ ENV \
|
||||
S6_SERVICES_GRACETIME=10000 \
|
||||
SUPERVISOR_API=http://localhost
|
||||
|
||||
ARG BUILD_ARCH
|
||||
ARG VCN_VERSION
|
||||
WORKDIR /usr/src
|
||||
ARG \
|
||||
CAS_VERSION
|
||||
|
||||
# Install base
|
||||
WORKDIR /usr/src
|
||||
RUN \
|
||||
set -x \
|
||||
&& apk add --no-cache \
|
||||
eudev \
|
||||
eudev-libs \
|
||||
git \
|
||||
glib \
|
||||
libffi \
|
||||
libpulse \
|
||||
musl \
|
||||
@@ -25,39 +24,22 @@ RUN \
|
||||
build-base \
|
||||
go \
|
||||
\
|
||||
&& git clone -b v${VCN_VERSION} --depth 1 \
|
||||
https://github.com/codenotary/vcn \
|
||||
&& cd vcn \
|
||||
\
|
||||
# Fix: https://github.com/codenotary/vcn/issues/131
|
||||
&& go get github.com/codenotary/immudb@4cf9e2ae06ac2e6ec98a60364c3de3eab5524757 \
|
||||
\
|
||||
&& if [ "${BUILD_ARCH}" = "armhf" ]; then \
|
||||
GOARM=6 GOARCH=arm go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
|
||||
elif [ "${BUILD_ARCH}" = "armv7" ]; then \
|
||||
GOARM=7 GOARCH=arm go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
|
||||
elif [ "${BUILD_ARCH}" = "aarch64" ]; then \
|
||||
GOARCH=arm64 go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
|
||||
elif [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||
GOARCH=386 go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
|
||||
elif [ "${BUILD_ARCH}" = "amd64" ]; then \
|
||||
GOARCH=amd64 go build -o vcn -ldflags="-s -w" ./cmd/vcn; \
|
||||
else \
|
||||
exit 1; \
|
||||
fi \
|
||||
\
|
||||
&& rm -rf /root/go /root/.cache \
|
||||
&& mv vcn /usr/bin/vcn \
|
||||
&& git clone -b "v${CAS_VERSION}" --depth 1 \
|
||||
https://github.com/codenotary/cas \
|
||||
&& cd cas \
|
||||
&& make cas \
|
||||
&& mv cas /usr/bin/cas \
|
||||
\
|
||||
&& apk del .build-dependencies \
|
||||
&& rm -rf /usr/src/vcn
|
||||
&& rm -rf /root/go /root/.cache \
|
||||
&& rm -rf /usr/src/cas
|
||||
|
||||
# Install requirements
|
||||
COPY requirements.txt .
|
||||
RUN \
|
||||
export MAKEFLAGS="-j$(nproc)" \
|
||||
&& pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links \
|
||||
"https://wheels.home-assistant.io/alpine-$(cut -d '.' -f 1-2 < /etc/alpine-release)/${BUILD_ARCH}/" \
|
||||
"https://wheels.home-assistant.io/musllinux/" \
|
||||
-r ./requirements.txt \
|
||||
&& rm -f requirements.txt
|
||||
|
||||
|
18
build.json
18
build.json
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"image": "homeassistant/{arch}-hassio-supervisor",
|
||||
"shadow_repository": "ghcr.io/home-assistant",
|
||||
"build_from": {
|
||||
"aarch64": "ghcr.io/home-assistant/aarch64-base-python:3.8-alpine3.13",
|
||||
"armhf": "ghcr.io/home-assistant/armhf-base-python:3.8-alpine3.13",
|
||||
"armv7": "ghcr.io/home-assistant/armv7-base-python:3.8-alpine3.13",
|
||||
"amd64": "ghcr.io/home-assistant/amd64-base-python:3.8-alpine3.13",
|
||||
"i386": "ghcr.io/home-assistant/i386-base-python:3.8-alpine3.13"
|
||||
},
|
||||
"args": {
|
||||
"VCN_VERSION": "0.9.4"
|
||||
},
|
||||
"labels": {
|
||||
"io.hass.type": "supervisor",
|
||||
"org.opencontainers.image.source": "https://github.com/home-assistant/supervisor"
|
||||
}
|
||||
}
|
22
build.yaml
Normal file
22
build.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
image: homeassistant/{arch}-hassio-supervisor
|
||||
shadow_repository: ghcr.io/home-assistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-base-python:3.10-alpine3.16
|
||||
armhf: ghcr.io/home-assistant/armhf-base-python:3.10-alpine3.16
|
||||
armv7: ghcr.io/home-assistant/armv7-base-python:3.10-alpine3.16
|
||||
amd64: ghcr.io/home-assistant/amd64-base-python:3.10-alpine3.16
|
||||
i386: ghcr.io/home-assistant/i386-base-python:3.10-alpine3.16
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
args:
|
||||
CAS_VERSION: 1.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: 8dd3d78f21...5bb9538861
11
pylintrc
11
pylintrc
@@ -2,31 +2,29 @@
|
||||
reports=no
|
||||
jobs=2
|
||||
|
||||
good-names=id,i,j,k,ex,Run,_,fp,T
|
||||
good-names=id,i,j,k,ex,Run,_,fp,T,os
|
||||
|
||||
extension-pkg-whitelist=
|
||||
ciso8601
|
||||
|
||||
# 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,
|
||||
@@ -37,6 +35,7 @@ disable=
|
||||
too-many-return-statements,
|
||||
too-many-statements,
|
||||
unused-argument,
|
||||
consider-using-with
|
||||
|
||||
[EXCEPTIONS]
|
||||
overgeneral-exceptions=Exception
|
||||
|
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
@@ -1,20 +1,25 @@
|
||||
aiohttp==3.7.4.post0
|
||||
async_timeout==3.0.1
|
||||
atomicwrites==1.4.0
|
||||
attrs==20.3.0
|
||||
awesomeversion==21.4.0
|
||||
aiodns==3.0.0
|
||||
aiohttp==3.8.3
|
||||
async_timeout==4.0.2
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==22.1.0
|
||||
awesomeversion==22.9.0
|
||||
brotli==1.0.9
|
||||
cchardet==2.1.7
|
||||
colorlog==4.8.0
|
||||
ciso8601==2.2.0
|
||||
colorlog==6.7.0
|
||||
cpe==1.2.1
|
||||
cryptography==3.4.6
|
||||
debugpy==1.2.1
|
||||
docker==5.0.0
|
||||
gitpython==3.1.14
|
||||
jinja2==2.11.3
|
||||
pulsectl==21.3.4
|
||||
pytz==2021.1
|
||||
pyudev==0.22.0
|
||||
ruamel.yaml==0.15.100
|
||||
sentry-sdk==1.0.0
|
||||
voluptuous==0.12.1
|
||||
cryptography==38.0.3
|
||||
debugpy==1.6.3
|
||||
deepmerge==1.1.0
|
||||
dirhash==0.2.1
|
||||
docker==6.0.1
|
||||
gitpython==3.1.29
|
||||
jinja2==3.1.2
|
||||
pulsectl==22.3.2
|
||||
pyudev==0.24.0
|
||||
ruamel.yaml==0.17.21
|
||||
securetar==2022.2.0
|
||||
sentry-sdk==1.10.1
|
||||
voluptuous==0.13.1
|
||||
dbus-fast==1.73.0
|
||||
|
@@ -1,14 +1,15 @@
|
||||
black==20.8b1
|
||||
codecov==2.1.11
|
||||
coverage==5.5
|
||||
black==22.10.0
|
||||
codecov==2.1.12
|
||||
coverage==6.5.0
|
||||
flake8-docstrings==1.6.0
|
||||
flake8==3.9.0
|
||||
pre-commit==2.12.0
|
||||
pydocstyle==6.0.0
|
||||
pylint==2.7.4
|
||||
pytest-aiohttp==0.3.0
|
||||
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==2.11.1
|
||||
pytest-timeout==1.4.2
|
||||
pytest==6.2.3
|
||||
pyupgrade==2.12.0
|
||||
flake8==5.0.4
|
||||
pre-commit==2.20.0
|
||||
pydocstyle==6.1.1
|
||||
pylint==2.15.5
|
||||
pytest-aiohttp==1.0.4
|
||||
pytest-asyncio==0.18.3
|
||||
pytest-cov==4.0.0
|
||||
pytest-timeout==2.1.0
|
||||
pytest==7.2.0
|
||||
pyupgrade==3.2.2
|
||||
time-machine==2.8.2
|
||||
|
0
rootfs/etc/cont-init.d/udev.sh
Normal file → Executable file
0
rootfs/etc/cont-init.d/udev.sh
Normal file → Executable file
11
rootfs/etc/services.d/supervisor/finish
Normal file → Executable file
11
rootfs/etc/services.d/supervisor/finish
Normal file → Executable file
@@ -1,8 +1,11 @@
|
||||
#!/usr/bin/execlineb -S1
|
||||
#!/usr/bin/env bashio
|
||||
# ==============================================================================
|
||||
# Take down the S6 supervision tree when Supervisor fails
|
||||
# ==============================================================================
|
||||
if { s6-test ${1} -ne 100 }
|
||||
if { s6-test ${1} -ne 256 }
|
||||
|
||||
redirfd -w 2 /dev/null s6-svscanctl -t /var/run/s6/services
|
||||
if [[ "$1" -ne 100 ]] && [[ "$1" -ne 256 ]]; then
|
||||
bashio::log.warning "Halt Supervisor"
|
||||
/run/s6/basedir/bin/halt
|
||||
fi
|
||||
|
||||
bashio::log.info "Supervisor restart after closing"
|
||||
|
1
rootfs/etc/services.d/supervisor/run
Normal file → Executable file
1
rootfs/etc/services.d/supervisor/run
Normal file → Executable file
@@ -3,5 +3,6 @@
|
||||
# Start Supervisor service
|
||||
# ==============================================================================
|
||||
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
|
||||
export MALLOC_CONF="background_thread:true,metadata_thp:auto"
|
||||
|
||||
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
|
||||
# ==============================================================================
|
||||
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"
|
||||
|
2
rootfs/etc/services.d/watchdog/run
Normal file → Executable file
2
rootfs/etc/services.d/watchdog/run
Normal file → Executable file
@@ -31,4 +31,4 @@ do
|
||||
|
||||
done
|
||||
|
||||
basio::exit.nok "Watchdog detected issue with Supervisor - taking container down!"
|
||||
bashio::exit.nok "Watchdog detected issue with Supervisor - taking container down!"
|
||||
|
4
rootfs/root/.cas-trusted-signing-pub-key
Normal file
4
rootfs/root/.cas-trusted-signing-pub-key
Normal file
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE03LvYuz79GTJx4uKp3w6NrSe5JZI
|
||||
iBtgzzYi0YQYtZO/r+xFpgDJEa0gLHkXtl94fpqrFiN89In83lzaszbZtA==
|
||||
-----END PUBLIC KEY-----
|
8
rootfs/root/.cas/config.json
Normal file
8
rootfs/root/.cas/config.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"currentcontext": {
|
||||
"LcHost": "cas.codenotary.com",
|
||||
"LcPort": "443"
|
||||
},
|
||||
"schemaversion": 3,
|
||||
"users": null
|
||||
}
|
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
source "${BASH_SOURCE[0]%/*}/common.sh"
|
||||
|
||||
set -eE
|
||||
|
||||
DOCKER_TIMEOUT=30
|
||||
DOCKER_PID=0
|
||||
|
||||
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 latest \
|
||||
--target /data \
|
||||
--test \
|
||||
--amd64 \
|
||||
--no-cache
|
||||
}
|
||||
|
||||
echo "Build Supervisor"
|
||||
start_docker
|
||||
trap "stop_docker" ERR
|
||||
|
||||
build_supervisor
|
@@ -1,58 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
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 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
|
||||
}
|
@@ -1,102 +0,0 @@
|
||||
#!/bin/bash
|
||||
source "${BASH_SOURCE[0]%/*}/common.sh"
|
||||
source "${BASH_SOURCE[0]%/*}/build-supervisor.sh"
|
||||
|
||||
set -eE
|
||||
|
||||
DOCKER_TIMEOUT=30
|
||||
DOCKER_PID=0
|
||||
|
||||
|
||||
function cleanup_docker() {
|
||||
echo "Cleaning up stopped containers..."
|
||||
docker rm $(docker ps -a -q) || true
|
||||
}
|
||||
|
||||
|
||||
function run_supervisor() {
|
||||
mkdir -p /workspaces/test_supervisor
|
||||
|
||||
echo "Start Supervisor"
|
||||
docker run --rm --privileged \
|
||||
--name hassio_supervisor \
|
||||
--privileged \
|
||||
--security-opt seccomp=unconfined \
|
||||
--security-opt apparmor:unconfined \
|
||||
-v /run/docker.sock:/run/docker.sock:rw \
|
||||
-v /run/dbus:/run/dbus:ro \
|
||||
-v /run/udev:/run/udev:ro \
|
||||
-v "/workspaces/test_supervisor":/data:rw \
|
||||
-v /etc/machine-id:/etc/machine-id:ro \
|
||||
-v /workspaces/supervisor:/usr/src/supervisor \
|
||||
-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
|
||||
}
|
||||
|
||||
|
||||
function init_udev() {
|
||||
if pgrep systemd-udevd; then
|
||||
echo "udev is running"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Startup udev"
|
||||
|
||||
# cleanups
|
||||
mkdir -p /run/udev
|
||||
|
||||
# run
|
||||
/lib/systemd/systemd-udevd --daemon
|
||||
sleep 3
|
||||
udevadm trigger && udevadm settle
|
||||
}
|
||||
|
||||
echo "Run Supervisor"
|
||||
|
||||
start_docker
|
||||
trap "stop_docker" ERR
|
||||
|
||||
|
||||
if [ "$( docker container inspect -f '{{.State.Status}}' hassio_supervisor )" == "running" ]; then
|
||||
echo "Restarting Supervisor"
|
||||
docker rm -f hassio_supervisor
|
||||
init_dbus
|
||||
init_udev
|
||||
cleanup_lastboot
|
||||
run_supervisor
|
||||
stop_docker
|
||||
|
||||
else
|
||||
echo "Starting Supervisor"
|
||||
docker system prune -f
|
||||
build_supervisor
|
||||
cleanup_lastboot
|
||||
cleanup_docker
|
||||
init_dbus
|
||||
init_udev
|
||||
run_supervisor
|
||||
stop_docker
|
||||
fi
|
@@ -1,4 +1,6 @@
|
||||
#!/bin/bash
|
||||
source "/etc/supervisor_scripts/common"
|
||||
|
||||
set -e
|
||||
|
||||
# Update frontend
|
||||
@@ -9,6 +11,10 @@ cd home-assistant-polymer
|
||||
nvm install
|
||||
script/bootstrap
|
||||
|
||||
# Download translations
|
||||
start_docker
|
||||
./script/translations_download
|
||||
|
||||
# build frontend
|
||||
cd hassio
|
||||
./script/build_hassio
|
||||
@@ -16,3 +22,9 @@ cd hassio
|
||||
# Copy frontend
|
||||
rm -rf ../../supervisor/api/panel/*
|
||||
cp -rf build/* ../../supervisor/api/panel/
|
||||
|
||||
# Reset frontend git
|
||||
cd ..
|
||||
git reset --hard HEAD
|
||||
|
||||
stop_docker
|
@@ -4,9 +4,8 @@ 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
|
||||
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||
default_section = THIRDPARTY
|
||||
forced_separate = tests
|
||||
combine_as_imports = true
|
||||
|
5
setup.py
5
setup.py
@@ -33,8 +33,9 @@ setup(
|
||||
packages=[
|
||||
"supervisor.addons",
|
||||
"supervisor.api",
|
||||
"supervisor.backups",
|
||||
"supervisor.dbus.network",
|
||||
"supervisor.dbus.payloads",
|
||||
"supervisor.dbus.network.setting",
|
||||
"supervisor.dbus",
|
||||
"supervisor.discovery.services",
|
||||
"supervisor.discovery",
|
||||
@@ -48,9 +49,9 @@ setup(
|
||||
"supervisor.resolution.evaluations",
|
||||
"supervisor.resolution.fixups",
|
||||
"supervisor.resolution",
|
||||
"supervisor.security",
|
||||
"supervisor.services.modules",
|
||||
"supervisor.services",
|
||||
"supervisor.snapshots",
|
||||
"supervisor.store",
|
||||
"supervisor.utils",
|
||||
"supervisor",
|
||||
|
@@ -28,7 +28,8 @@ if __name__ == "__main__":
|
||||
bootstrap.initialize_logging()
|
||||
|
||||
# Init async event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# Check if all information are available to setup Supervisor
|
||||
bootstrap.check_environment()
|
||||
@@ -39,6 +40,7 @@ if __name__ == "__main__":
|
||||
|
||||
_LOGGER.info("Initializing Supervisor setup")
|
||||
coresys = loop.run_until_complete(bootstrap.initialize_coresys())
|
||||
loop.set_debug(coresys.config.debug)
|
||||
loop.run_until_complete(coresys.core.connect())
|
||||
|
||||
bootstrap.supervisor_debugger(coresys)
|
||||
|
@@ -3,7 +3,7 @@ import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import tarfile
|
||||
from typing import Dict, List, Optional, Union
|
||||
from typing import Union
|
||||
|
||||
from ..const import AddonBoot, AddonStartup, AddonState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
@@ -23,7 +23,9 @@ 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__)
|
||||
@@ -38,21 +40,21 @@ class AddonManager(CoreSysAttributes):
|
||||
"""Initialize Docker base wrapper."""
|
||||
self.coresys: CoreSys = coresys
|
||||
self.data: AddonsData = AddonsData(coresys)
|
||||
self.local: Dict[str, Addon] = {}
|
||||
self.store: Dict[str, AddonStore] = {}
|
||||
self.local: dict[str, Addon] = {}
|
||||
self.store: dict[str, AddonStore] = {}
|
||||
|
||||
@property
|
||||
def all(self) -> List[AnyAddon]:
|
||||
def all(self) -> list[AnyAddon]:
|
||||
"""Return a list of all add-ons."""
|
||||
addons: Dict[str, AnyAddon] = {**self.store, **self.local}
|
||||
addons: dict[str, AnyAddon] = {**self.store, **self.local}
|
||||
return list(addons.values())
|
||||
|
||||
@property
|
||||
def installed(self) -> List[Addon]:
|
||||
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]:
|
||||
def get(self, addon_slug: str, local_only: bool = False) -> AnyAddon | None:
|
||||
"""Return an add-on from slug.
|
||||
|
||||
Prio:
|
||||
@@ -65,7 +67,7 @@ class AddonManager(CoreSysAttributes):
|
||||
return self.store.get(addon_slug)
|
||||
return None
|
||||
|
||||
def from_token(self, token: str) -> Optional[Addon]:
|
||||
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:
|
||||
@@ -89,7 +91,7 @@ class AddonManager(CoreSysAttributes):
|
||||
|
||||
async def boot(self, stage: AddonStartup) -> None:
|
||||
"""Boot add-ons with mode auto."""
|
||||
tasks: List[Addon] = []
|
||||
tasks: list[Addon] = []
|
||||
for addon in self.installed:
|
||||
if addon.boot != AddonBoot.AUTO or addon.startup != stage:
|
||||
continue
|
||||
@@ -113,7 +115,7 @@ class AddonManager(CoreSysAttributes):
|
||||
addon.boot = AddonBoot.MANUAL
|
||||
addon.save_persist()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
self.sys_capture_exception(err)
|
||||
capture_exception(err)
|
||||
else:
|
||||
continue
|
||||
|
||||
@@ -123,7 +125,7 @@ class AddonManager(CoreSysAttributes):
|
||||
|
||||
async def shutdown(self, stage: AddonStartup) -> None:
|
||||
"""Shutdown addons."""
|
||||
tasks: List[Addon] = []
|
||||
tasks: list[Addon] = []
|
||||
for addon in self.installed:
|
||||
if addon.state != AddonState.STARTED or addon.startup != stage:
|
||||
continue
|
||||
@@ -141,14 +143,10 @@ class AddonManager(CoreSysAttributes):
|
||||
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)
|
||||
capture_exception(err)
|
||||
|
||||
@Job(
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.HEALTHY,
|
||||
],
|
||||
conditions=ADDON_UPDATE_CONDITIONS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def install(self, slug: str) -> None:
|
||||
@@ -158,15 +156,16 @@ class AddonManager(CoreSysAttributes):
|
||||
store = self.store.get(slug)
|
||||
|
||||
if not store:
|
||||
raise AddonsError(f"Add-on {slug} not exists", _LOGGER.error)
|
||||
raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error)
|
||||
|
||||
if not store.available:
|
||||
raise AddonsNotSupportedError(
|
||||
f"Add-on {slug} not supported on that platform", _LOGGER.error
|
||||
f"Add-on {slug} not supported on this platform", _LOGGER.error
|
||||
)
|
||||
|
||||
self.data.install(store)
|
||||
addon = Addon(self.coresys, slug)
|
||||
await addon.load()
|
||||
|
||||
if not addon.path_data.is_dir():
|
||||
_LOGGER.info(
|
||||
@@ -178,7 +177,7 @@ class AddonManager(CoreSysAttributes):
|
||||
await addon.install_apparmor()
|
||||
|
||||
try:
|
||||
await addon.instance.install(store.version, store.image)
|
||||
await addon.instance.install(store.version, store.image, arch=addon.arch)
|
||||
except DockerError as err:
|
||||
self.data.uninstall(addon)
|
||||
raise AddonsError() from err
|
||||
@@ -205,7 +204,7 @@ class AddonManager(CoreSysAttributes):
|
||||
else:
|
||||
addon.state = AddonState.UNKNOWN
|
||||
|
||||
await addon.remove_data()
|
||||
await addon.unload()
|
||||
|
||||
# Cleanup audio settings
|
||||
if addon.path_pulse.exists():
|
||||
@@ -245,14 +244,10 @@ class AddonManager(CoreSysAttributes):
|
||||
_LOGGER.info("Add-on '%s' successfully removed", slug)
|
||||
|
||||
@Job(
|
||||
conditions=[
|
||||
JobCondition.FREE_SPACE,
|
||||
JobCondition.INTERNET_HOST,
|
||||
JobCondition.HEALTHY,
|
||||
],
|
||||
conditions=ADDON_UPDATE_CONDITIONS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def update(self, slug: str) -> None:
|
||||
async def update(self, slug: str, backup: bool | None = False) -> None:
|
||||
"""Update add-on."""
|
||||
if slug not in self.local:
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
@@ -273,6 +268,13 @@ class AddonManager(CoreSysAttributes):
|
||||
f"Add-on {slug} not supported on that platform", _LOGGER.error
|
||||
)
|
||||
|
||||
if backup:
|
||||
await self.sys_backups.do_backup_partial(
|
||||
name=f"addon_{addon.slug}_{addon.version}",
|
||||
homeassistant=False,
|
||||
addons=[addon.slug],
|
||||
)
|
||||
|
||||
# Update instance
|
||||
last_state: AddonState = addon.state
|
||||
old_image = addon.image
|
||||
@@ -306,22 +308,24 @@ class AddonManager(CoreSysAttributes):
|
||||
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()
|
||||
raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error)
|
||||
addon = self.local[slug]
|
||||
|
||||
if addon.is_detached:
|
||||
_LOGGER.error("Add-on %s is not available inside store", slug)
|
||||
raise AddonsError()
|
||||
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:
|
||||
_LOGGER.error("Version changed, use Update instead Rebuild")
|
||||
raise AddonsError()
|
||||
raise AddonsError(
|
||||
"Version changed, use Update instead Rebuild", _LOGGER.error
|
||||
)
|
||||
if not addon.need_build:
|
||||
_LOGGER.error("Can't rebuild a image based add-on")
|
||||
raise AddonsNotSupportedError()
|
||||
raise AddonsNotSupportedError(
|
||||
"Can't rebuild a image based add-on", _LOGGER.error
|
||||
)
|
||||
|
||||
# remove docker container but not addon config
|
||||
last_state: AddonState = addon.state
|
||||
@@ -371,7 +375,7 @@ class AddonManager(CoreSysAttributes):
|
||||
@Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST])
|
||||
async def repair(self) -> None:
|
||||
"""Repair local add-ons."""
|
||||
needs_repair: List[Addon] = []
|
||||
needs_repair: list[Addon] = []
|
||||
|
||||
# Evaluate Add-ons to repair
|
||||
for addon in self.installed:
|
||||
@@ -418,7 +422,7 @@ class AddonManager(CoreSysAttributes):
|
||||
reference=addon.slug,
|
||||
suggestions=[SuggestionType.EXECUTE_REPAIR],
|
||||
)
|
||||
self.sys_capture_exception(err)
|
||||
capture_exception(err)
|
||||
else:
|
||||
self.sys_plugins.dns.add_host(
|
||||
ipv4=addon.ip_address, names=[addon.hostname], write=False
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Init file for Supervisor add-ons."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import suppress
|
||||
from copy import deepcopy
|
||||
from ipaddress import IPv4Address
|
||||
@@ -10,12 +11,15 @@ import secrets
|
||||
import shutil
|
||||
import tarfile
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Awaitable, Dict, List, Optional, Set
|
||||
from typing import Any, Final
|
||||
|
||||
import aiohttp
|
||||
from deepmerge import Merger
|
||||
from securetar import atomic_contents_add, secure_path
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from ..bus import EventListener
|
||||
from ..const import (
|
||||
ATTR_ACCESS_TOKEN,
|
||||
ATTR_AUDIO_INPUT,
|
||||
@@ -46,29 +50,40 @@ from ..const import (
|
||||
AddonBoot,
|
||||
AddonStartup,
|
||||
AddonState,
|
||||
BusEvent,
|
||||
)
|
||||
from ..coresys import CoreSys
|
||||
from ..docker.addon import DockerAddon
|
||||
from ..docker.const import ContainerState
|
||||
from ..docker.monitor import DockerContainerStateEvent
|
||||
from ..docker.stats import DockerStats
|
||||
from ..exceptions import (
|
||||
AddonConfigurationError,
|
||||
AddonsError,
|
||||
AddonsJobError,
|
||||
AddonsNotSupportedError,
|
||||
ConfigurationFileError,
|
||||
DockerError,
|
||||
DockerRequestError,
|
||||
HostAppArmorError,
|
||||
)
|
||||
from ..hardware.data import Device
|
||||
from ..homeassistant.const import WSEvent, WSType
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from ..utils import check_port
|
||||
from ..utils.apparmor import adjust_profile
|
||||
from ..utils.json import read_json_file, write_json_file
|
||||
from ..utils.tar import atomic_contents_add, secure_path
|
||||
from .const import (
|
||||
WATCHDOG_MAX_ATTEMPTS,
|
||||
WATCHDOG_RETRY_SECONDS,
|
||||
WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
WATCHDOG_THROTTLE_PERIOD,
|
||||
AddonBackupMode,
|
||||
)
|
||||
from .model import AddonModel, Data
|
||||
from .options import AddonOptions
|
||||
from .utils import remove_data
|
||||
from .validate import SCHEMA_ADDON_SNAPSHOT
|
||||
from .validate import SCHEMA_ADDON_BACKUP
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,10 +97,14 @@ RE_WATCHDOG = re.compile(
|
||||
r":\/\/\[HOST\]:(?:\[PORT:)?(?P<t_port>\d+)\]?(?P<s_suffix>.*)$"
|
||||
)
|
||||
|
||||
RE_OLD_AUDIO = re.compile(r"\d+,\d+")
|
||||
|
||||
WATCHDOG_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||
|
||||
_OPTIONS_MERGER: Final = Merger(
|
||||
type_strategies=[(dict, ["merge"])],
|
||||
fallback_strategies=["override"],
|
||||
type_conflict_strategies=["override"],
|
||||
)
|
||||
|
||||
|
||||
class Addon(AddonModel):
|
||||
"""Hold data for add-on inside Supervisor."""
|
||||
@@ -95,6 +114,58 @@ class Addon(AddonModel):
|
||||
super().__init__(coresys, slug)
|
||||
self.instance: DockerAddon = DockerAddon(coresys, self)
|
||||
self._state: AddonState = AddonState.UNKNOWN
|
||||
self._manual_stop: bool = (
|
||||
self.sys_hardware.helper.last_boot != self.sys_config.last_boot
|
||||
)
|
||||
self._listeners: list[EventListener] = []
|
||||
|
||||
@Job(
|
||||
name=f"addon_{slug}_restart_after_problem",
|
||||
limit=JobExecutionLimit.THROTTLE_RATE_LIMIT,
|
||||
throttle_period=WATCHDOG_THROTTLE_PERIOD,
|
||||
throttle_max_calls=WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def restart_after_problem(addon: Addon, state: ContainerState):
|
||||
"""Restart unhealthy or failed addon."""
|
||||
attempts = 0
|
||||
while await addon.instance.current_state() == state:
|
||||
if not addon.in_progress:
|
||||
_LOGGER.warning(
|
||||
"Watchdog found addon %s is %s, restarting...",
|
||||
addon.name,
|
||||
state.value,
|
||||
)
|
||||
try:
|
||||
if state == ContainerState.FAILED:
|
||||
# Ensure failed container is removed before attempting reanimation
|
||||
if attempts == 0:
|
||||
with suppress(DockerError):
|
||||
await addon.instance.stop(remove_container=True)
|
||||
|
||||
await addon.start()
|
||||
else:
|
||||
await addon.restart()
|
||||
except AddonsError as err:
|
||||
attempts = attempts + 1
|
||||
_LOGGER.error(
|
||||
"Watchdog restart of addon %s failed!", addon.name
|
||||
)
|
||||
addon.sys_capture_exception(err)
|
||||
else:
|
||||
break
|
||||
|
||||
if attempts >= WATCHDOG_MAX_ATTEMPTS:
|
||||
_LOGGER.critical(
|
||||
"Watchdog cannot restart addon %s, failed all %s attempts",
|
||||
addon.name,
|
||||
attempts,
|
||||
)
|
||||
break
|
||||
|
||||
await asyncio.sleep(WATCHDOG_RETRY_SECONDS)
|
||||
|
||||
self._restart_after_problem = restart_after_problem
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return internal representation."""
|
||||
@@ -111,7 +182,7 @@ class Addon(AddonModel):
|
||||
if self._state == new_state:
|
||||
return
|
||||
self._state = new_state
|
||||
self.sys_homeassistant.websocket.send_command(
|
||||
self.sys_homeassistant.websocket.send_message(
|
||||
{
|
||||
ATTR_TYPE: WSType.SUPERVISOR_EVENT,
|
||||
ATTR_DATA: {
|
||||
@@ -129,15 +200,20 @@ class Addon(AddonModel):
|
||||
|
||||
async def load(self) -> None:
|
||||
"""Async initialize of object."""
|
||||
self._listeners.append(
|
||||
self.sys_bus.register_event(
|
||||
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.container_state_changed
|
||||
)
|
||||
)
|
||||
self._listeners.append(
|
||||
self.sys_bus.register_event(
|
||||
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.watchdog_container
|
||||
)
|
||||
)
|
||||
|
||||
with suppress(DockerError):
|
||||
await self.instance.attach(version=self.version)
|
||||
|
||||
# Evaluate state
|
||||
if await self.instance.is_running():
|
||||
self.state = AddonState.STARTED
|
||||
else:
|
||||
self.state = AddonState.STOPPED
|
||||
|
||||
@property
|
||||
def ip_address(self) -> IPv4Address:
|
||||
"""Return IP of add-on instance."""
|
||||
@@ -174,7 +250,7 @@ class Addon(AddonModel):
|
||||
return self._available(self.data_store)
|
||||
|
||||
@property
|
||||
def version(self) -> Optional[str]:
|
||||
def version(self) -> str | None:
|
||||
"""Return installed version."""
|
||||
return self.persist[ATTR_VERSION]
|
||||
|
||||
@@ -186,17 +262,19 @@ class Addon(AddonModel):
|
||||
return self.version != self.latest_version
|
||||
|
||||
@property
|
||||
def dns(self) -> List[str]:
|
||||
def dns(self) -> list[str]:
|
||||
"""Return list of DNS name for that add-on."""
|
||||
return [f"{self.hostname}.{DNS_SUFFIX}"]
|
||||
|
||||
@property
|
||||
def options(self) -> Dict[str, Any]:
|
||||
def options(self) -> dict[str, Any]:
|
||||
"""Return options with local changes."""
|
||||
return {**self.data[ATTR_OPTIONS], **self.persist[ATTR_OPTIONS]}
|
||||
return _OPTIONS_MERGER.merge(
|
||||
deepcopy(self.data[ATTR_OPTIONS]), deepcopy(self.persist[ATTR_OPTIONS])
|
||||
)
|
||||
|
||||
@options.setter
|
||||
def options(self, value: Optional[Dict[str, Any]]) -> None:
|
||||
def options(self, value: dict[str, Any] | None) -> None:
|
||||
"""Store user add-on options."""
|
||||
self.persist[ATTR_OPTIONS] = {} if value is None else deepcopy(value)
|
||||
|
||||
@@ -241,17 +319,17 @@ class Addon(AddonModel):
|
||||
return self.persist[ATTR_UUID]
|
||||
|
||||
@property
|
||||
def supervisor_token(self) -> Optional[str]:
|
||||
def supervisor_token(self) -> str | None:
|
||||
"""Return access token for Supervisor API."""
|
||||
return self.persist.get(ATTR_ACCESS_TOKEN)
|
||||
|
||||
@property
|
||||
def ingress_token(self) -> Optional[str]:
|
||||
def ingress_token(self) -> str | None:
|
||||
"""Return access token for Supervisor API."""
|
||||
return self.persist.get(ATTR_INGRESS_TOKEN)
|
||||
|
||||
@property
|
||||
def ingress_entry(self) -> Optional[str]:
|
||||
def ingress_entry(self) -> str | None:
|
||||
"""Return ingress external URL."""
|
||||
if self.with_ingress:
|
||||
return f"/api/hassio_ingress/{self.ingress_token}"
|
||||
@@ -273,12 +351,12 @@ class Addon(AddonModel):
|
||||
self.persist[ATTR_PROTECTED] = value
|
||||
|
||||
@property
|
||||
def ports(self) -> Optional[Dict[str, Optional[int]]]:
|
||||
def ports(self) -> dict[str, int | None] | None:
|
||||
"""Return ports of add-on."""
|
||||
return self.persist.get(ATTR_NETWORK, super().ports)
|
||||
|
||||
@ports.setter
|
||||
def ports(self, value: Optional[Dict[str, Optional[int]]]) -> None:
|
||||
def ports(self, value: dict[str, int | None] | None) -> None:
|
||||
"""Set custom ports of add-on."""
|
||||
if value is None:
|
||||
self.persist.pop(ATTR_NETWORK, None)
|
||||
@@ -293,7 +371,7 @@ class Addon(AddonModel):
|
||||
self.persist[ATTR_NETWORK] = new_ports
|
||||
|
||||
@property
|
||||
def ingress_url(self) -> Optional[str]:
|
||||
def ingress_url(self) -> str | None:
|
||||
"""Return URL to ingress url."""
|
||||
if not self.with_ingress:
|
||||
return None
|
||||
@@ -304,7 +382,7 @@ class Addon(AddonModel):
|
||||
return url
|
||||
|
||||
@property
|
||||
def webui(self) -> Optional[str]:
|
||||
def webui(self) -> str | None:
|
||||
"""Return URL to webui or None."""
|
||||
url = super().webui
|
||||
if not url:
|
||||
@@ -332,7 +410,7 @@ class Addon(AddonModel):
|
||||
return f"{proto}://[HOST]:{port}{s_suffix}"
|
||||
|
||||
@property
|
||||
def ingress_port(self) -> Optional[int]:
|
||||
def ingress_port(self) -> int | None:
|
||||
"""Return Ingress port."""
|
||||
if not self.with_ingress:
|
||||
return None
|
||||
@@ -343,8 +421,11 @@ class Addon(AddonModel):
|
||||
return port
|
||||
|
||||
@property
|
||||
def ingress_panel(self) -> Optional[bool]:
|
||||
def ingress_panel(self) -> bool | None:
|
||||
"""Return True if the add-on access support ingress."""
|
||||
if not self.with_ingress:
|
||||
return None
|
||||
|
||||
return self.persist[ATTR_INGRESS_PANEL]
|
||||
|
||||
@ingress_panel.setter
|
||||
@@ -353,43 +434,32 @@ class Addon(AddonModel):
|
||||
self.persist[ATTR_INGRESS_PANEL] = value
|
||||
|
||||
@property
|
||||
def audio_output(self) -> Optional[str]:
|
||||
def audio_output(self) -> str | None:
|
||||
"""Return a pulse profile for output or None."""
|
||||
if not self.with_audio:
|
||||
return None
|
||||
|
||||
# Fallback with old audio settings
|
||||
# Remove after 210
|
||||
output_data = self.persist.get(ATTR_AUDIO_OUTPUT)
|
||||
if output_data and RE_OLD_AUDIO.fullmatch(output_data):
|
||||
return None
|
||||
return output_data
|
||||
return self.persist.get(ATTR_AUDIO_OUTPUT)
|
||||
|
||||
@audio_output.setter
|
||||
def audio_output(self, value: Optional[str]):
|
||||
def audio_output(self, value: str | None):
|
||||
"""Set audio output profile settings."""
|
||||
self.persist[ATTR_AUDIO_OUTPUT] = value
|
||||
|
||||
@property
|
||||
def audio_input(self) -> Optional[str]:
|
||||
def audio_input(self) -> str | None:
|
||||
"""Return pulse profile for input or None."""
|
||||
if not self.with_audio:
|
||||
return None
|
||||
|
||||
# Fallback with old audio settings
|
||||
# Remove after 210
|
||||
input_data = self.persist.get(ATTR_AUDIO_INPUT)
|
||||
if input_data and RE_OLD_AUDIO.fullmatch(input_data):
|
||||
return None
|
||||
return input_data
|
||||
return self.persist.get(ATTR_AUDIO_INPUT)
|
||||
|
||||
@audio_input.setter
|
||||
def audio_input(self, value: Optional[str]) -> None:
|
||||
def audio_input(self, value: str | None) -> None:
|
||||
"""Set audio input settings."""
|
||||
self.persist[ATTR_AUDIO_INPUT] = value
|
||||
|
||||
@property
|
||||
def image(self) -> Optional[str]:
|
||||
def image(self) -> str | None:
|
||||
"""Return image name of add-on."""
|
||||
return self.persist.get(ATTR_IMAGE)
|
||||
|
||||
@@ -398,6 +468,11 @@ class Addon(AddonModel):
|
||||
"""Return True if this add-on need a local build."""
|
||||
return ATTR_IMAGE not in self.data
|
||||
|
||||
@property
|
||||
def latest_need_build(self) -> bool:
|
||||
"""Return True if the latest version of the addon needs a local build."""
|
||||
return ATTR_IMAGE not in self.data_store
|
||||
|
||||
@property
|
||||
def path_data(self) -> Path:
|
||||
"""Return add-on data path inside Supervisor."""
|
||||
@@ -424,32 +499,27 @@ class Addon(AddonModel):
|
||||
return Path(self.sys_config.path_extern_tmp, f"{self.slug}_pulse")
|
||||
|
||||
@property
|
||||
def devices(self) -> Set[Device]:
|
||||
def devices(self) -> set[Device]:
|
||||
"""Extract devices from add-on options."""
|
||||
raw_schema = self.data[ATTR_SCHEMA]
|
||||
if isinstance(raw_schema, bool) or not raw_schema:
|
||||
return set()
|
||||
|
||||
# Validate devices
|
||||
options_validator = AddonOptions(self.coresys, raw_schema, self.name, self.slug)
|
||||
options_schema = self.schema
|
||||
with suppress(vol.Invalid):
|
||||
options_validator(self.options)
|
||||
options_schema.validate(self.options)
|
||||
|
||||
return options_validator.devices
|
||||
return options_schema.devices
|
||||
|
||||
@property
|
||||
def pwned(self) -> Set[str]:
|
||||
def pwned(self) -> set[str]:
|
||||
"""Extract pwned data for add-on options."""
|
||||
raw_schema = self.data[ATTR_SCHEMA]
|
||||
if isinstance(raw_schema, bool) or not raw_schema:
|
||||
return set()
|
||||
|
||||
# Validate devices
|
||||
options_validator = AddonOptions(self.coresys, raw_schema, self.name, self.slug)
|
||||
options_schema = self.schema
|
||||
with suppress(vol.Invalid):
|
||||
options_validator(self.options)
|
||||
options_schema.validate(self.options)
|
||||
|
||||
return options_validator.pwned
|
||||
return options_schema.pwned
|
||||
|
||||
@property
|
||||
def loaded(self) -> bool:
|
||||
"""Is add-on loaded."""
|
||||
return bool(self._listeners)
|
||||
|
||||
def save_persist(self) -> None:
|
||||
"""Save data of add-on."""
|
||||
@@ -463,7 +533,7 @@ class Addon(AddonModel):
|
||||
application = RE_WATCHDOG.match(url)
|
||||
|
||||
# extract arguments
|
||||
t_port = application.group("t_port")
|
||||
t_port = int(application.group("t_port"))
|
||||
t_proto = application.group("t_proto")
|
||||
s_prefix = application.group("s_prefix") or ""
|
||||
s_suffix = application.group("s_suffix") or ""
|
||||
@@ -487,8 +557,8 @@ class Addon(AddonModel):
|
||||
# Make HTTP request
|
||||
try:
|
||||
url = f"{proto}://{self.ip_address}:{port}{s_suffix}"
|
||||
async with self.sys_websession_ssl.get(
|
||||
url, timeout=WATCHDOG_TIMEOUT
|
||||
async with self.sys_websession.get(
|
||||
url, timeout=WATCHDOG_TIMEOUT, ssl=False
|
||||
) as req:
|
||||
if req.status < 300:
|
||||
return True
|
||||
@@ -503,7 +573,7 @@ class Addon(AddonModel):
|
||||
await self.sys_homeassistant.secrets.reload()
|
||||
|
||||
try:
|
||||
options = self.schema(self.options)
|
||||
options = self.schema.validate(self.options)
|
||||
write_json_file(self.path_options, options)
|
||||
except vol.Invalid as ex:
|
||||
_LOGGER.error(
|
||||
@@ -519,8 +589,11 @@ class Addon(AddonModel):
|
||||
|
||||
raise AddonConfigurationError()
|
||||
|
||||
async def remove_data(self) -> None:
|
||||
"""Remove add-on data."""
|
||||
async def unload(self) -> None:
|
||||
"""Unload add-on and remove data."""
|
||||
for listener in self._listeners:
|
||||
self.sys_bus.remove_listener(listener)
|
||||
|
||||
if not self.path_data.is_dir():
|
||||
return
|
||||
|
||||
@@ -539,8 +612,7 @@ class Addon(AddonModel):
|
||||
|
||||
# Write pulse config
|
||||
try:
|
||||
with self.path_pulse.open("w") as config_file:
|
||||
config_file.write(pulse_config)
|
||||
self.path_pulse.write_text(pulse_config, encoding="utf-8")
|
||||
except OSError as err:
|
||||
_LOGGER.error(
|
||||
"Add-on %s can't write pulse/client.config: %s", self.slug, err
|
||||
@@ -588,7 +660,9 @@ class Addon(AddonModel):
|
||||
return True
|
||||
|
||||
# merge options
|
||||
options = {**self.persist[ATTR_OPTIONS], **default_options}
|
||||
options = _OPTIONS_MERGER.merge(
|
||||
deepcopy(default_options), deepcopy(self.persist[ATTR_OPTIONS])
|
||||
)
|
||||
|
||||
# create voluptuous
|
||||
new_schema = vol.Schema(
|
||||
@@ -625,27 +699,18 @@ class Addon(AddonModel):
|
||||
# Start Add-on
|
||||
try:
|
||||
await self.instance.run()
|
||||
except DockerRequestError as err:
|
||||
self.state = AddonState.ERROR
|
||||
raise AddonsError() from err
|
||||
except DockerError as err:
|
||||
self.state = AddonState.ERROR
|
||||
raise AddonsError() from err
|
||||
else:
|
||||
self.state = AddonState.STARTED
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop add-on."""
|
||||
self._manual_stop = True
|
||||
try:
|
||||
await self.instance.stop()
|
||||
except DockerRequestError as err:
|
||||
self.state = AddonState.ERROR
|
||||
raise AddonsError() from err
|
||||
except DockerError as err:
|
||||
self.state = AddonState.ERROR
|
||||
raise AddonsError() from err
|
||||
else:
|
||||
self.state = AddonState.STOPPED
|
||||
|
||||
async def restart(self) -> None:
|
||||
"""Restart add-on."""
|
||||
@@ -680,16 +745,36 @@ class Addon(AddonModel):
|
||||
Return a coroutine.
|
||||
"""
|
||||
if not self.with_stdin:
|
||||
_LOGGER.error("Add-on %s does not support writing to stdin!", self.slug)
|
||||
raise AddonsNotSupportedError()
|
||||
raise AddonsNotSupportedError(
|
||||
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
|
||||
)
|
||||
|
||||
try:
|
||||
return await self.instance.write_stdin(data)
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
|
||||
async def snapshot(self, tar_file: tarfile.TarFile) -> None:
|
||||
"""Snapshot state of an add-on."""
|
||||
async def _backup_command(self, command: str) -> None:
|
||||
try:
|
||||
command_return = await self.instance.run_inside(command)
|
||||
if command_return.exit_code != 0:
|
||||
_LOGGER.debug(
|
||||
"Pre-/Post backup command failed with: %s", command_return.output
|
||||
)
|
||||
raise AddonsError(
|
||||
f"Pre-/Post backup command returned error code: {command_return.exit_code}",
|
||||
_LOGGER.error,
|
||||
)
|
||||
except DockerError as err:
|
||||
raise AddonsError(
|
||||
f"Failed running pre-/post backup command {command}: {str(err)}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
|
||||
async def backup(self, tar_file: tarfile.TarFile) -> None:
|
||||
"""Backup state of an add-on."""
|
||||
is_running = await self.is_running()
|
||||
|
||||
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
|
||||
temp_path = Path(temp)
|
||||
|
||||
@@ -711,59 +796,82 @@ class Addon(AddonModel):
|
||||
try:
|
||||
write_json_file(temp_path.joinpath("addon.json"), data)
|
||||
except ConfigurationFileError as err:
|
||||
_LOGGER.error("Can't save meta for %s", self.slug)
|
||||
raise AddonsError() from err
|
||||
raise AddonsError(
|
||||
f"Can't save meta for {self.slug}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
# Store AppArmor Profile
|
||||
if self.sys_host.apparmor.exists(self.slug):
|
||||
profile = temp_path.joinpath("apparmor.txt")
|
||||
try:
|
||||
self.sys_host.apparmor.backup_profile(self.slug, profile)
|
||||
await self.sys_host.apparmor.backup_profile(self.slug, profile)
|
||||
except HostAppArmorError as err:
|
||||
_LOGGER.error("Can't backup AppArmor profile")
|
||||
raise AddonsError() from err
|
||||
raise AddonsError(
|
||||
"Can't backup AppArmor profile", _LOGGER.error
|
||||
) from err
|
||||
|
||||
# write into tarfile
|
||||
def _write_tarfile():
|
||||
"""Write tar inside loop."""
|
||||
with tar_file as snapshot:
|
||||
# Snapshot system
|
||||
with tar_file as backup:
|
||||
# Backup metadata
|
||||
backup.add(temp, arcname=".")
|
||||
|
||||
snapshot.add(temp, arcname=".")
|
||||
|
||||
# Snapshot data
|
||||
# Backup data
|
||||
atomic_contents_add(
|
||||
snapshot,
|
||||
backup,
|
||||
self.path_data,
|
||||
excludes=self.snapshot_exclude,
|
||||
excludes=self.backup_exclude,
|
||||
arcname="data",
|
||||
)
|
||||
|
||||
if (
|
||||
is_running
|
||||
and self.backup_mode == AddonBackupMode.HOT
|
||||
and self.backup_pre is not None
|
||||
):
|
||||
await self._backup_command(self.backup_pre)
|
||||
elif is_running and self.backup_mode == AddonBackupMode.COLD:
|
||||
_LOGGER.info("Shutdown add-on %s for cold backup", self.slug)
|
||||
await self.instance.stop()
|
||||
|
||||
try:
|
||||
_LOGGER.info("Building snapshot for add-on %s", self.slug)
|
||||
_LOGGER.info("Building backup for add-on %s", self.slug)
|
||||
await self.sys_run_in_executor(_write_tarfile)
|
||||
except (tarfile.TarError, OSError) as err:
|
||||
_LOGGER.error("Can't write tarfile %s: %s", tar_file, err)
|
||||
raise AddonsError() from err
|
||||
raise AddonsError(
|
||||
f"Can't write tarfile {tar_file}: {err}", _LOGGER.error
|
||||
) from err
|
||||
finally:
|
||||
if (
|
||||
is_running
|
||||
and self.backup_mode == AddonBackupMode.HOT
|
||||
and self.backup_post is not None
|
||||
):
|
||||
await self._backup_command(self.backup_post)
|
||||
elif is_running and self.backup_mode is AddonBackupMode.COLD:
|
||||
_LOGGER.info("Starting add-on %s again", self.slug)
|
||||
await self.start()
|
||||
|
||||
_LOGGER.info("Finish snapshot for addon %s", self.slug)
|
||||
_LOGGER.info("Finish backup for addon %s", self.slug)
|
||||
|
||||
async def restore(self, tar_file: tarfile.TarFile) -> None:
|
||||
"""Restore state of an add-on."""
|
||||
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
|
||||
# extract snapshot
|
||||
# extract backup
|
||||
def _extract_tarfile():
|
||||
"""Extract tar snapshot."""
|
||||
with tar_file as snapshot:
|
||||
snapshot.extractall(path=Path(temp), members=secure_path(snapshot))
|
||||
"""Extract tar backup."""
|
||||
with tar_file as backup:
|
||||
backup.extractall(path=Path(temp), members=secure_path(backup))
|
||||
|
||||
try:
|
||||
await self.sys_run_in_executor(_extract_tarfile)
|
||||
except tarfile.TarError as err:
|
||||
_LOGGER.error("Can't read tarfile %s: %s", tar_file, err)
|
||||
raise AddonsError() from err
|
||||
raise AddonsError(
|
||||
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
# Read snapshot data
|
||||
# Read backup data
|
||||
try:
|
||||
data = read_json_file(Path(temp, "addon.json"))
|
||||
except ConfigurationFileError as err:
|
||||
@@ -771,19 +879,19 @@ class Addon(AddonModel):
|
||||
|
||||
# Validate
|
||||
try:
|
||||
data = SCHEMA_ADDON_SNAPSHOT(data)
|
||||
data = SCHEMA_ADDON_BACKUP(data)
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error(
|
||||
"Can't validate %s, snapshot data: %s",
|
||||
self.slug,
|
||||
humanize_error(data, err),
|
||||
)
|
||||
raise AddonsError() from err
|
||||
raise AddonsError(
|
||||
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}",
|
||||
_LOGGER.error,
|
||||
) from err
|
||||
|
||||
# If available
|
||||
if not self._available(data[ATTR_SYSTEM]):
|
||||
_LOGGER.error("Add-on %s is not available for this platform", self.slug)
|
||||
raise AddonsNotSupportedError()
|
||||
raise AddonsNotSupportedError(
|
||||
f"Add-on {self.slug} is not available for this platform",
|
||||
_LOGGER.error,
|
||||
)
|
||||
|
||||
# Restore local add-on information
|
||||
_LOGGER.info("Restore config for addon %s", self.slug)
|
||||
@@ -816,7 +924,11 @@ class Addon(AddonModel):
|
||||
# Restore data
|
||||
def _restore_data():
|
||||
"""Restore data."""
|
||||
shutil.copytree(Path(temp, "data"), self.path_data, symlinks=True)
|
||||
temp_data = Path(temp, "data")
|
||||
if temp_data.is_dir():
|
||||
shutil.copytree(temp_data, self.path_data, symlinks=True)
|
||||
else:
|
||||
self.path_data.mkdir()
|
||||
|
||||
_LOGGER.info("Restoring data for addon %s", self.slug)
|
||||
if self.path_data.is_dir():
|
||||
@@ -824,8 +936,9 @@ class Addon(AddonModel):
|
||||
try:
|
||||
await self.sys_run_in_executor(_restore_data)
|
||||
except shutil.Error as err:
|
||||
_LOGGER.error("Can't restore origin data: %s", err)
|
||||
raise AddonsError() from err
|
||||
raise AddonsError(
|
||||
f"Can't restore origin data: {err}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
# Restore AppArmor
|
||||
profile_file = Path(temp, "apparmor.txt")
|
||||
@@ -838,8 +951,52 @@ class Addon(AddonModel):
|
||||
)
|
||||
raise AddonsError() from err
|
||||
|
||||
# Is add-on loaded
|
||||
if not self.loaded:
|
||||
await self.load()
|
||||
|
||||
# Run add-on
|
||||
if data[ATTR_STATE] == AddonState.STARTED:
|
||||
return await self.start()
|
||||
|
||||
_LOGGER.info("Finished restore for add-on %s", self.slug)
|
||||
|
||||
def check_trust(self) -> Awaitable[None]:
|
||||
"""Calculate Addon docker content trust.
|
||||
|
||||
Return Coroutine.
|
||||
"""
|
||||
return self.instance.check_trust()
|
||||
|
||||
async def container_state_changed(self, event: DockerContainerStateEvent) -> None:
|
||||
"""Set addon state from container state."""
|
||||
if event.name != self.instance.name:
|
||||
return
|
||||
|
||||
if event.state in [
|
||||
ContainerState.RUNNING,
|
||||
ContainerState.HEALTHY,
|
||||
ContainerState.UNHEALTHY,
|
||||
]:
|
||||
self._manual_stop = False
|
||||
self.state = AddonState.STARTED
|
||||
elif event.state == ContainerState.STOPPED:
|
||||
self.state = AddonState.STOPPED
|
||||
elif event.state == ContainerState.FAILED:
|
||||
self.state = AddonState.ERROR
|
||||
|
||||
async def watchdog_container(self, event: DockerContainerStateEvent) -> None:
|
||||
"""Process state changes in addon container and restart if necessary."""
|
||||
if event.name != self.instance.name:
|
||||
return
|
||||
|
||||
# Skip watchdog if not enabled or manual stopped
|
||||
if not self.watchdog or self._manual_stop:
|
||||
return
|
||||
|
||||
if event.state in [
|
||||
ContainerState.FAILED,
|
||||
ContainerState.STOPPED,
|
||||
ContainerState.UNHEALTHY,
|
||||
]:
|
||||
await self._restart_after_problem(self, event.state)
|
||||
|
@@ -2,18 +2,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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 ..docker.interface import MAP_ARCH
|
||||
from ..exceptions import ConfigurationFileError
|
||||
from ..utils.common import FileConfiguration, find_one_filetype
|
||||
from .validate import SCHEMA_BUILD_CONFIG
|
||||
@@ -46,9 +48,15 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
@property
|
||||
def base_image(self) -> str:
|
||||
"""Return base image for this add-on."""
|
||||
return self._data[ATTR_BUILD_FROM].get(
|
||||
self.sys_arch.default, f"homeassistant/{self.sys_arch.default}-base:latest"
|
||||
)
|
||||
if not self._data[ATTR_BUILD_FROM]:
|
||||
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
|
||||
arch = self.sys_arch.match(list(self._data[ATTR_BUILD_FROM].keys()))
|
||||
return self._data[ATTR_BUILD_FROM][arch]
|
||||
|
||||
@property
|
||||
def squash(self) -> bool:
|
||||
@@ -56,10 +64,15 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
return self._data[ATTR_SQUASH]
|
||||
|
||||
@property
|
||||
def additional_args(self) -> Dict[str, str]:
|
||||
def additional_args(self) -> dict[str, str]:
|
||||
"""Return additional Docker build arguments."""
|
||||
return self._data[ATTR_ARGS]
|
||||
|
||||
@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."""
|
||||
@@ -76,14 +89,16 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
"path": str(self.addon.path_location),
|
||||
"tag": f"{self.addon.image}:{version!s}",
|
||||
"pull": True,
|
||||
"forcerm": True,
|
||||
"forcerm": not self.sys_dev,
|
||||
"squash": self.squash,
|
||||
"platform": MAP_ARCH[self.sys_arch.match(self.addon.arch)],
|
||||
"labels": {
|
||||
"io.hass.version": version,
|
||||
"io.hass.arch": self.sys_arch.default,
|
||||
"io.hass.type": META_ADDON,
|
||||
"io.hass.name": self._fix_label("name"),
|
||||
"io.hass.description": self._fix_label("description"),
|
||||
**self.additional_labels,
|
||||
},
|
||||
"buildargs": {
|
||||
"BUILD_FROM": self.base_image,
|
||||
|
28
supervisor/addons/const.py
Normal file
28
supervisor/addons/const.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Add-on static data."""
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
|
||||
from ..jobs.const import JobCondition
|
||||
|
||||
|
||||
class AddonBackupMode(str, Enum):
|
||||
"""Backup mode of an Add-on."""
|
||||
|
||||
HOT = "hot"
|
||||
COLD = "cold"
|
||||
|
||||
|
||||
ATTR_BACKUP = "backup"
|
||||
ATTR_CODENOTARY = "codenotary"
|
||||
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,
|
||||
]
|
@@ -1,6 +1,6 @@
|
||||
"""Init file for Supervisor add-on data."""
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from ..const import (
|
||||
ATTR_IMAGE,
|
||||
@@ -16,7 +16,7 @@ from ..utils.common import FileConfiguration
|
||||
from .addon import Addon
|
||||
from .validate import SCHEMA_ADDONS_FILE
|
||||
|
||||
Config = Dict[str, Any]
|
||||
Config = dict[str, Any]
|
||||
|
||||
|
||||
class AddonsData(FileConfiguration, CoreSysAttributes):
|
||||
|
@@ -1,10 +1,10 @@
|
||||
"""Init file for Supervisor add-ons."""
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
from awesomeversion import AwesomeVersion, AwesomeVersionException
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import (
|
||||
ATTR_ADVANCED,
|
||||
@@ -12,6 +12,9 @@ from ..const import (
|
||||
ATTR_ARCH,
|
||||
ATTR_AUDIO,
|
||||
ATTR_AUTH_API,
|
||||
ATTR_BACKUP_EXCLUDE,
|
||||
ATTR_BACKUP_POST,
|
||||
ATTR_BACKUP_PRE,
|
||||
ATTR_BOOT,
|
||||
ATTR_DESCRIPTON,
|
||||
ATTR_DEVICES,
|
||||
@@ -31,6 +34,7 @@ from ..const import (
|
||||
ATTR_HOST_PID,
|
||||
ATTR_IMAGE,
|
||||
ATTR_INGRESS,
|
||||
ATTR_INGRESS_STREAM,
|
||||
ATTR_INIT,
|
||||
ATTR_JOURNALD,
|
||||
ATTR_KERNEL_MODULES,
|
||||
@@ -51,7 +55,6 @@ from ..const import (
|
||||
ATTR_SCHEMA,
|
||||
ATTR_SERVICES,
|
||||
ATTR_SLUG,
|
||||
ATTR_SNAPSHOT_EXCLUDE,
|
||||
ATTR_STAGE,
|
||||
ATTR_STARTUP,
|
||||
ATTR_STDIN,
|
||||
@@ -75,10 +78,11 @@ from ..const import (
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..docker.const import Capabilities
|
||||
from .const import ATTR_BACKUP, ATTR_CODENOTARY, AddonBackupMode
|
||||
from .options import AddonOptions, UiOptions
|
||||
from .validate import RE_SERVICE, RE_VOLUME
|
||||
|
||||
Data = Dict[str, Any]
|
||||
Data = dict[str, Any]
|
||||
|
||||
|
||||
class AddonModel(CoreSysAttributes, ABC):
|
||||
@@ -110,7 +114,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self._available(self.data)
|
||||
|
||||
@property
|
||||
def options(self) -> Dict[str, Any]:
|
||||
def options(self) -> dict[str, Any]:
|
||||
"""Return options with local changes."""
|
||||
return self.data[ATTR_OPTIONS]
|
||||
|
||||
@@ -120,7 +124,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_BOOT]
|
||||
|
||||
@property
|
||||
def auto_update(self) -> Optional[bool]:
|
||||
def auto_update(self) -> bool | None:
|
||||
"""Return if auto update is enable."""
|
||||
return None
|
||||
|
||||
@@ -135,7 +139,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.slug.replace("_", "-")
|
||||
|
||||
@property
|
||||
def dns(self) -> List[str]:
|
||||
def dns(self) -> list[str]:
|
||||
"""Return list of DNS name for that add-on."""
|
||||
return []
|
||||
|
||||
@@ -145,22 +149,22 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_TIMEOUT]
|
||||
|
||||
@property
|
||||
def uuid(self) -> Optional[str]:
|
||||
def uuid(self) -> str | None:
|
||||
"""Return an API token for this add-on."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def supervisor_token(self) -> Optional[str]:
|
||||
def supervisor_token(self) -> str | None:
|
||||
"""Return access token for Supervisor API."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def ingress_token(self) -> Optional[str]:
|
||||
def ingress_token(self) -> str | None:
|
||||
"""Return access token for Supervisor API."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def ingress_entry(self) -> Optional[str]:
|
||||
def ingress_entry(self) -> str | None:
|
||||
"""Return ingress external URL."""
|
||||
return None
|
||||
|
||||
@@ -170,7 +174,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_DESCRIPTON]
|
||||
|
||||
@property
|
||||
def long_description(self) -> Optional[str]:
|
||||
def long_description(self) -> str | None:
|
||||
"""Return README.md as long_description."""
|
||||
readme = Path(self.path_location, "README.md")
|
||||
|
||||
@@ -179,8 +183,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return None
|
||||
|
||||
# Return data
|
||||
with readme.open("r") as readme_file:
|
||||
return readme_file.read()
|
||||
return readme.read_text(encoding="utf-8")
|
||||
|
||||
@property
|
||||
def repository(self) -> str:
|
||||
@@ -223,7 +226,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_STAGE]
|
||||
|
||||
@property
|
||||
def services_role(self) -> Dict[str, str]:
|
||||
def services_role(self) -> dict[str, str]:
|
||||
"""Return dict of services with rights."""
|
||||
services_list = self.data.get(ATTR_SERVICES, [])
|
||||
|
||||
@@ -236,37 +239,37 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return services
|
||||
|
||||
@property
|
||||
def discovery(self) -> List[str]:
|
||||
def discovery(self) -> list[str]:
|
||||
"""Return list of discoverable components/platforms."""
|
||||
return self.data.get(ATTR_DISCOVERY, [])
|
||||
|
||||
@property
|
||||
def ports_description(self) -> Optional[Dict[str, str]]:
|
||||
def ports_description(self) -> dict[str, str] | None:
|
||||
"""Return descriptions of ports."""
|
||||
return self.data.get(ATTR_PORTS_DESCRIPTION)
|
||||
|
||||
@property
|
||||
def ports(self) -> Optional[Dict[str, Optional[int]]]:
|
||||
def ports(self) -> dict[str, int | None] | None:
|
||||
"""Return ports of add-on."""
|
||||
return self.data.get(ATTR_PORTS)
|
||||
|
||||
@property
|
||||
def ingress_url(self) -> Optional[str]:
|
||||
def ingress_url(self) -> str | None:
|
||||
"""Return URL to ingress url."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def webui(self) -> Optional[str]:
|
||||
def webui(self) -> str | None:
|
||||
"""Return URL to webui or None."""
|
||||
return self.data.get(ATTR_WEBUI)
|
||||
|
||||
@property
|
||||
def watchdog(self) -> Optional[str]:
|
||||
def watchdog(self) -> str | None:
|
||||
"""Return URL to for watchdog or None."""
|
||||
return self.data.get(ATTR_WATCHDOG)
|
||||
|
||||
@property
|
||||
def ingress_port(self) -> Optional[int]:
|
||||
def ingress_port(self) -> int | None:
|
||||
"""Return Ingress port."""
|
||||
return None
|
||||
|
||||
@@ -306,17 +309,17 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_HOST_DBUS]
|
||||
|
||||
@property
|
||||
def static_devices(self) -> List[Path]:
|
||||
def static_devices(self) -> list[Path]:
|
||||
"""Return static devices of add-on."""
|
||||
return [Path(node) for node in self.data.get(ATTR_DEVICES, [])]
|
||||
|
||||
@property
|
||||
def environment(self) -> Optional[Dict[str, str]]:
|
||||
def environment(self) -> dict[str, str] | None:
|
||||
"""Return environment of add-on."""
|
||||
return self.data.get(ATTR_ENVIRONMENT)
|
||||
|
||||
@property
|
||||
def privileged(self) -> List[Capabilities]:
|
||||
def privileged(self) -> list[Capabilities]:
|
||||
"""Return list of privilege."""
|
||||
return self.data.get(ATTR_PRIVILEGED, [])
|
||||
|
||||
@@ -355,9 +358,24 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_HASSIO_ROLE]
|
||||
|
||||
@property
|
||||
def snapshot_exclude(self) -> List[str]:
|
||||
"""Return Exclude list for snapshot."""
|
||||
return self.data.get(ATTR_SNAPSHOT_EXCLUDE, [])
|
||||
def backup_exclude(self) -> list[str]:
|
||||
"""Return Exclude list for backup."""
|
||||
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
|
||||
def default_init(self) -> bool:
|
||||
@@ -375,10 +393,15 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_INGRESS]
|
||||
|
||||
@property
|
||||
def ingress_panel(self) -> Optional[bool]:
|
||||
def ingress_panel(self) -> bool | None:
|
||||
"""Return True if the add-on access support ingress."""
|
||||
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
|
||||
def with_gpio(self) -> bool:
|
||||
"""Return True if the add-on access to GPIO interface."""
|
||||
@@ -420,7 +443,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_DEVICETREE]
|
||||
|
||||
@property
|
||||
def with_tmpfs(self) -> Optional[str]:
|
||||
def with_tmpfs(self) -> str | None:
|
||||
"""Return if tmp is in memory of add-on."""
|
||||
return self.data[ATTR_TMPFS]
|
||||
|
||||
@@ -440,12 +463,12 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.data[ATTR_VIDEO]
|
||||
|
||||
@property
|
||||
def homeassistant_version(self) -> Optional[str]:
|
||||
def homeassistant_version(self) -> str | None:
|
||||
"""Return min Home Assistant version they needed by Add-on."""
|
||||
return self.data.get(ATTR_HOMEASSISTANT)
|
||||
|
||||
@property
|
||||
def url(self) -> Optional[str]:
|
||||
def url(self) -> str | None:
|
||||
"""Return URL of add-on."""
|
||||
return self.data.get(ATTR_URL)
|
||||
|
||||
@@ -470,17 +493,25 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return self.path_documentation.exists()
|
||||
|
||||
@property
|
||||
def supported_arch(self) -> List[str]:
|
||||
def supported_arch(self) -> list[str]:
|
||||
"""Return list of supported arch."""
|
||||
return self.data[ATTR_ARCH]
|
||||
|
||||
@property
|
||||
def supported_machine(self) -> List[str]:
|
||||
def supported_machine(self) -> list[str]:
|
||||
"""Return list of supported machine."""
|
||||
return self.data.get(ATTR_MACHINE, [])
|
||||
|
||||
@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."""
|
||||
return self._image(self.data)
|
||||
|
||||
@@ -490,7 +521,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return ATTR_IMAGE not in self.data
|
||||
|
||||
@property
|
||||
def map_volumes(self) -> Dict[str, str]:
|
||||
def map_volumes(self) -> dict[str, str]:
|
||||
"""Return a dict of {volume: policy} from add-on."""
|
||||
volumes = {}
|
||||
for volume in self.data[ATTR_MAP]:
|
||||
@@ -532,18 +563,16 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return Path(self.path_location, "apparmor.txt")
|
||||
|
||||
@property
|
||||
def schema(self) -> vol.Schema:
|
||||
"""Create a schema for add-on options."""
|
||||
def schema(self) -> AddonOptions:
|
||||
"""Return Addon options validation object."""
|
||||
raw_schema = self.data[ATTR_SCHEMA]
|
||||
|
||||
if isinstance(raw_schema, bool):
|
||||
raw_schema = {}
|
||||
return vol.Schema(
|
||||
vol.All(dict, AddonOptions(self.coresys, raw_schema, self.name, self.slug))
|
||||
)
|
||||
|
||||
return AddonOptions(self.coresys, raw_schema, self.name, self.slug)
|
||||
|
||||
@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."""
|
||||
raw_schema = self.data[ATTR_SCHEMA]
|
||||
|
||||
@@ -556,6 +585,16 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
"""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 __eq__(self, other):
|
||||
"""Compaired add-on objects."""
|
||||
if not isinstance(other, AddonModel):
|
||||
@@ -576,7 +615,7 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
return False
|
||||
|
||||
# Home Assistant
|
||||
version: Optional[AwesomeVersion] = config.get(ATTR_HOMEASSISTANT)
|
||||
version: AwesomeVersion | None = config.get(ATTR_HOMEASSISTANT)
|
||||
try:
|
||||
return self.sys_homeassistant.version >= version
|
||||
except (AwesomeVersionException, TypeError):
|
||||
@@ -600,9 +639,9 @@ class AddonModel(CoreSysAttributes, ABC):
|
||||
"""Uninstall this add-on."""
|
||||
return self.sys_addons.uninstall(self.slug)
|
||||
|
||||
def update(self) -> Awaitable[None]:
|
||||
def update(self, backup: bool | None = False) -> Awaitable[None]:
|
||||
"""Update this add-on."""
|
||||
return self.sys_addons.update(self.slug)
|
||||
return self.sys_addons.update(self.slug, backup=backup)
|
||||
|
||||
def rebuild(self) -> Awaitable[None]:
|
||||
"""Rebuild this add-on."""
|
||||
|
@@ -3,7 +3,7 @@ import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Dict, List, Set, Union
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -59,16 +59,21 @@ class AddonOptions(CoreSysAttributes):
|
||||
"""Validate Add-ons Options."""
|
||||
|
||||
def __init__(
|
||||
self, coresys: CoreSys, raw_schema: Dict[str, Any], name: str, slug: str
|
||||
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.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 = {}
|
||||
@@ -162,7 +167,7 @@ class AddonOptions(CoreSysAttributes):
|
||||
device = self.sys_hardware.get_by_path(Path(value))
|
||||
except HardwareNotFound:
|
||||
raise vol.Invalid(
|
||||
f"Device '{value}' does not exists! in {self._name} ({self._slug})"
|
||||
f"Device '{value}' does not exist in {self._name} ({self._slug})"
|
||||
) from None
|
||||
|
||||
# Have filter
|
||||
@@ -182,7 +187,7 @@ class AddonOptions(CoreSysAttributes):
|
||||
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):
|
||||
def _nested_validate_list(self, typ: Any, data_list: list[Any], key: str):
|
||||
"""Validate nested items."""
|
||||
options = []
|
||||
|
||||
@@ -204,7 +209,7 @@ class AddonOptions(CoreSysAttributes):
|
||||
return options
|
||||
|
||||
def _nested_validate_dict(
|
||||
self, typ: Dict[Any, Any], data_dict: Dict[Any, Any], key: str
|
||||
self, typ: dict[Any, Any], data_dict: dict[Any, Any], key: str
|
||||
):
|
||||
"""Validate nested items."""
|
||||
options = {}
|
||||
@@ -236,7 +241,7 @@ class AddonOptions(CoreSysAttributes):
|
||||
return options
|
||||
|
||||
def _check_missing_options(
|
||||
self, origin: Dict[Any, Any], exists: Dict[Any, Any], root: str
|
||||
self, origin: dict[Any, Any], exists: dict[Any, Any], root: str
|
||||
) -> None:
|
||||
"""Check if all options are exists."""
|
||||
missing = set(origin) - set(exists)
|
||||
@@ -262,9 +267,9 @@ class UiOptions(CoreSysAttributes):
|
||||
"""Initialize UI option render."""
|
||||
self.coresys = coresys
|
||||
|
||||
def __call__(self, raw_schema: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
def __call__(self, raw_schema: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Generate UI schema."""
|
||||
ui_schema: List[Dict[str, Any]] = []
|
||||
ui_schema: list[dict[str, Any]] = []
|
||||
|
||||
# read options
|
||||
for key, value in raw_schema.items():
|
||||
@@ -282,13 +287,13 @@ class UiOptions(CoreSysAttributes):
|
||||
|
||||
def _single_ui_option(
|
||||
self,
|
||||
ui_schema: List[Dict[str, Any]],
|
||||
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}
|
||||
ui_node: dict[str, str | bool | float | list[str]] = {"name": key}
|
||||
|
||||
# If multiple
|
||||
if multiple:
|
||||
@@ -360,8 +365,8 @@ class UiOptions(CoreSysAttributes):
|
||||
|
||||
def _nested_ui_list(
|
||||
self,
|
||||
ui_schema: List[Dict[str, Any]],
|
||||
option_list: List[Any],
|
||||
ui_schema: list[dict[str, Any]],
|
||||
option_list: list[Any],
|
||||
key: str,
|
||||
) -> None:
|
||||
"""UI nested list items."""
|
||||
@@ -378,8 +383,8 @@ class UiOptions(CoreSysAttributes):
|
||||
|
||||
def _nested_ui_dict(
|
||||
self,
|
||||
ui_schema: List[Dict[str, Any]],
|
||||
option_dict: Dict[str, Any],
|
||||
ui_schema: list[dict[str, Any]],
|
||||
option_dict: dict[str, Any],
|
||||
key: str,
|
||||
multiple: bool = False,
|
||||
) -> None:
|
||||
@@ -403,7 +408,7 @@ class UiOptions(CoreSysAttributes):
|
||||
ui_schema.append(ui_node)
|
||||
|
||||
|
||||
def _create_device_filter(str_filter: str) -> Dict[str, Any]:
|
||||
def _create_device_filter(str_filter: str) -> dict[str, Any]:
|
||||
"""Generate device Filter."""
|
||||
raw_filter = dict(value.split("=") for value in str_filter.split(";"))
|
||||
|
||||
|
@@ -16,10 +16,10 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def rating_security(addon: AddonModel) -> int:
|
||||
"""Return 1-6 for security rating.
|
||||
"""Return 1-8 for security rating.
|
||||
|
||||
1 = not secure
|
||||
6 = high secure
|
||||
8 = high secure
|
||||
"""
|
||||
rating = 5
|
||||
|
||||
@@ -35,6 +35,10 @@ def rating_security(addon: AddonModel) -> int:
|
||||
elif addon.access_auth_api:
|
||||
rating += 1
|
||||
|
||||
# Signed
|
||||
if addon.signed:
|
||||
rating += 1
|
||||
|
||||
# Privileged options
|
||||
if (
|
||||
any(
|
||||
@@ -70,7 +74,7 @@ def rating_security(addon: AddonModel) -> int:
|
||||
if addon.access_docker_api or addon.with_full_access:
|
||||
rating = 1
|
||||
|
||||
return max(min(6, rating), 1)
|
||||
return max(min(8, rating), 1)
|
||||
|
||||
|
||||
async def remove_data(folder: Path) -> None:
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -19,6 +19,9 @@ from ..const import (
|
||||
ATTR_AUDIO_OUTPUT,
|
||||
ATTR_AUTH_API,
|
||||
ATTR_AUTO_UPDATE,
|
||||
ATTR_BACKUP_EXCLUDE,
|
||||
ATTR_BACKUP_POST,
|
||||
ATTR_BACKUP_PRE,
|
||||
ATTR_BOOT,
|
||||
ATTR_BUILD_FROM,
|
||||
ATTR_CONFIGURATION,
|
||||
@@ -43,10 +46,12 @@ from ..const import (
|
||||
ATTR_INGRESS_ENTRY,
|
||||
ATTR_INGRESS_PANEL,
|
||||
ATTR_INGRESS_PORT,
|
||||
ATTR_INGRESS_STREAM,
|
||||
ATTR_INGRESS_TOKEN,
|
||||
ATTR_INIT,
|
||||
ATTR_JOURNALD,
|
||||
ATTR_KERNEL_MODULES,
|
||||
ATTR_LABELS,
|
||||
ATTR_LEGACY,
|
||||
ATTR_LOCATON,
|
||||
ATTR_MACHINE,
|
||||
@@ -66,7 +71,6 @@ from ..const import (
|
||||
ATTR_SCHEMA,
|
||||
ATTR_SERVICES,
|
||||
ATTR_SLUG,
|
||||
ATTR_SNAPSHOT_EXCLUDE,
|
||||
ATTR_SQUASH,
|
||||
ATTR_STAGE,
|
||||
ATTR_STARTUP,
|
||||
@@ -104,6 +108,7 @@ from ..validate import (
|
||||
uuid_match,
|
||||
version_tag,
|
||||
)
|
||||
from .const import ATTR_BACKUP, ATTR_CODENOTARY, AddonBackupMode
|
||||
from .options import RE_SCHEMA_ELEMENT
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -121,6 +126,7 @@ SCHEMA_ELEMENT = vol.Match(RE_SCHEMA_ELEMENT)
|
||||
RE_MACHINE = re.compile(
|
||||
r"^!?(?:"
|
||||
r"|intel-nuc"
|
||||
r"|generic-x86-64"
|
||||
r"|odroid-c2"
|
||||
r"|odroid-c4"
|
||||
r"|odroid-n2"
|
||||
@@ -140,7 +146,7 @@ RE_MACHINE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _warn_addon_config(config: Dict[str, Any]):
|
||||
def _warn_addon_config(config: dict[str, Any]):
|
||||
"""Warn about miss configs."""
|
||||
name = config.get(ATTR_NAME)
|
||||
if not name:
|
||||
@@ -157,13 +163,21 @@ def _warn_addon_config(config: Dict[str, Any]):
|
||||
name,
|
||||
)
|
||||
|
||||
if config.get(ATTR_BACKUP, AddonBackupMode.HOT) == AddonBackupMode.COLD and (
|
||||
config.get(ATTR_BACKUP_POST) or config.get(ATTR_BACKUP_PRE)
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Add-on which only support COLD backups trying to use post/pre commands. Please report this to the maintainer of %s",
|
||||
name,
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _migrate_addon_config(protocol=False):
|
||||
"""Migrate addon config."""
|
||||
|
||||
def _migrate(config: Dict[str, Any]):
|
||||
def _migrate(config: dict[str, Any]):
|
||||
name = config.get(ATTR_NAME)
|
||||
if not name:
|
||||
raise vol.Invalid("Invalid Add-on config!")
|
||||
@@ -209,6 +223,23 @@ def _migrate_addon_config(protocol=False):
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
return _migrate
|
||||
@@ -244,10 +275,11 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
|
||||
network_port, vol.Equal(0)
|
||||
),
|
||||
vol.Optional(ATTR_INGRESS_ENTRY): str,
|
||||
vol.Optional(ATTR_INGRESS_STREAM, default=False): vol.Boolean(),
|
||||
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_HOMEASSISTANT): vol.Maybe(version_tag),
|
||||
vol.Optional(ATTR_HOMEASSISTANT): version_tag,
|
||||
vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(),
|
||||
@@ -277,7 +309,13 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
|
||||
vol.Optional(ATTR_AUTH_API, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
|
||||
vol.Optional(ATTR_DISCOVERY): [valid_discovery_service],
|
||||
vol.Optional(ATTR_SNAPSHOT_EXCLUDE): [str],
|
||||
vol.Optional(ATTR_BACKUP_EXCLUDE): [str],
|
||||
vol.Optional(ATTR_BACKUP_PRE): str,
|
||||
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(
|
||||
@@ -313,13 +351,13 @@ SCHEMA_ADDON_CONFIG = vol.All(
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_BUILD_CONFIG = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Schema(
|
||||
{vol.In(ARCH_ALL): vol.Match(RE_DOCKER_IMAGE_BUILD)}
|
||||
vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Any(
|
||||
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_ARGS, default=dict): vol.Schema(
|
||||
{vol.Coerce(str): vol.Coerce(str)}
|
||||
),
|
||||
vol.Optional(ATTR_ARGS, default=dict): vol.Schema({str: str}),
|
||||
vol.Optional(ATTR_LABELS, default=dict): vol.Schema({str: str}),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
@@ -386,7 +424,7 @@ SCHEMA_ADDONS_FILE = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
SCHEMA_ADDON_SNAPSHOT = vol.Schema(
|
||||
SCHEMA_ADDON_BACKUP = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_USER): SCHEMA_ADDON_USER,
|
||||
vol.Required(ATTR_SYSTEM): SCHEMA_ADDON_SYSTEM,
|
||||
|
@@ -1,14 +1,18 @@
|
||||
"""Init file for Supervisor RESTful API."""
|
||||
from functools import partial
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..const import AddonState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import APIAddonNotInstalled
|
||||
from .addons import APIAddons
|
||||
from .audio import APIAudio
|
||||
from .auth import APIAuth
|
||||
from .backups import APIBackups
|
||||
from .cli import APICli
|
||||
from .discovery import APIDiscovery
|
||||
from .dns import APICoreDNS
|
||||
@@ -16,25 +20,27 @@ from .docker import APIDocker
|
||||
from .hardware import APIHardware
|
||||
from .homeassistant import APIHomeAssistant
|
||||
from .host import APIHost
|
||||
from .info import APIInfo
|
||||
from .ingress import APIIngress
|
||||
from .jobs import APIJobs
|
||||
from .middleware.security import SecurityMiddleware
|
||||
from .multicast import APIMulticast
|
||||
from .network import APINetwork
|
||||
from .observer import APIObserver
|
||||
from .os import APIOS
|
||||
from .proxy import APIProxy
|
||||
from .resolution import APIResoulution
|
||||
from .security import SecurityMiddleware
|
||||
from .root import APIRoot
|
||||
from .security import APISecurity
|
||||
from .services import APIServices
|
||||
from .snapshots import APISnapshots
|
||||
from .store import APIStore
|
||||
from .supervisor import APISupervisor
|
||||
from .utils import api_process
|
||||
|
||||
_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):
|
||||
@@ -50,17 +56,22 @@ class RestAPI(CoreSysAttributes):
|
||||
self.security.system_validation,
|
||||
self.security.token_validation,
|
||||
],
|
||||
handler_args={
|
||||
"max_line_size": MAX_LINE_SIZE,
|
||||
"max_field_size": MAX_LINE_SIZE,
|
||||
},
|
||||
)
|
||||
|
||||
# service stuff
|
||||
self._runner: web.AppRunner = web.AppRunner(self.webapp)
|
||||
self._site: Optional[web.TCPSite] = None
|
||||
self._site: web.TCPSite | None = None
|
||||
|
||||
async def load(self) -> None:
|
||||
"""Register REST API Calls."""
|
||||
self._register_addons()
|
||||
self._register_audio()
|
||||
self._register_auth()
|
||||
self._register_backups()
|
||||
self._register_cli()
|
||||
self._register_discovery()
|
||||
self._register_dns()
|
||||
@@ -68,7 +79,7 @@ class RestAPI(CoreSysAttributes):
|
||||
self._register_hardware()
|
||||
self._register_homeassistant()
|
||||
self._register_host()
|
||||
self._register_info()
|
||||
self._register_root()
|
||||
self._register_ingress()
|
||||
self._register_multicast()
|
||||
self._register_network()
|
||||
@@ -79,9 +90,9 @@ class RestAPI(CoreSysAttributes):
|
||||
self._register_proxy()
|
||||
self._register_resolution()
|
||||
self._register_services()
|
||||
self._register_snapshots()
|
||||
self._register_supervisor()
|
||||
self._register_store()
|
||||
self._register_security()
|
||||
|
||||
await self.start()
|
||||
|
||||
@@ -93,16 +104,36 @@ class RestAPI(CoreSysAttributes):
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
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/shutdown", api_host.shutdown),
|
||||
web.post("/host/reload", api_host.reload),
|
||||
web.post("/host/options", api_host.options),
|
||||
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),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -143,6 +174,30 @@ class RestAPI(CoreSysAttributes):
|
||||
web.get("/os/info", api_os.info),
|
||||
web.post("/os/update", api_os.update),
|
||||
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/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),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -209,16 +264,24 @@ class RestAPI(CoreSysAttributes):
|
||||
[
|
||||
web.get("/hardware/info", api_hardware.info),
|
||||
web.get("/hardware/audio", api_hardware.audio),
|
||||
web.post("/hardware/trigger", api_hardware.trigger),
|
||||
]
|
||||
)
|
||||
|
||||
def _register_info(self) -> None:
|
||||
"""Register info functions."""
|
||||
api_info = APIInfo()
|
||||
api_info.coresys = self.coresys
|
||||
def _register_root(self) -> None:
|
||||
"""Register root functions."""
|
||||
api_root = APIRoot()
|
||||
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:
|
||||
"""Register info functions."""
|
||||
@@ -244,6 +307,10 @@ class RestAPI(CoreSysAttributes):
|
||||
"/resolution/issue/{issue}",
|
||||
api_resolution.dismiss_issue,
|
||||
),
|
||||
web.get(
|
||||
"/resolution/issue/{issue}/suggestions",
|
||||
api_resolution.suggestions_for_issue,
|
||||
),
|
||||
web.post("/resolution/healthcheck", api_resolution.healthcheck),
|
||||
]
|
||||
)
|
||||
@@ -298,17 +365,22 @@ class RestAPI(CoreSysAttributes):
|
||||
web.post("/core/start", api_hass.start),
|
||||
web.post("/core/check", api_hass.check),
|
||||
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/logs", api_hass.logs),
|
||||
web.get("/homeassistant/stats", api_hass.stats),
|
||||
web.post("/homeassistant/options", api_hass.options),
|
||||
web.post("/homeassistant/update", api_hass.update),
|
||||
web.post("/homeassistant/restart", api_hass.restart),
|
||||
web.post("/homeassistant/stop", api_hass.stop),
|
||||
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/check", api_hass.check),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -325,7 +397,12 @@ class RestAPI(CoreSysAttributes):
|
||||
web.post("/core/api/{path:.+}", api_proxy.api),
|
||||
web.get("/core/api/{path:.+}", 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/websocket", api_proxy.websocket),
|
||||
web.get("/homeassistant/api/stream", api_proxy.stream),
|
||||
@@ -343,8 +420,6 @@ class RestAPI(CoreSysAttributes):
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
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}/uninstall", api_addons.uninstall),
|
||||
web.post("/addons/{addon}/start", api_addons.start),
|
||||
web.post("/addons/{addon}/stop", api_addons.stop),
|
||||
@@ -356,16 +431,31 @@ class RestAPI(CoreSysAttributes):
|
||||
web.get("/addons/{addon}/options/config", api_addons.options_config),
|
||||
web.post("/addons/{addon}/rebuild", api_addons.rebuild),
|
||||
web.get("/addons/{addon}/logs", api_addons.logs),
|
||||
web.get("/addons/{addon}/icon", api_addons.icon),
|
||||
web.get("/addons/{addon}/logo", api_addons.logo),
|
||||
web.get("/addons/{addon}/changelog", api_addons.changelog),
|
||||
web.get("/addons/{addon}/documentation", api_addons.documentation),
|
||||
web.post("/addons/{addon}/stdin", api_addons.stdin),
|
||||
web.post("/addons/{addon}/security", api_addons.security),
|
||||
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:
|
||||
"""Register Ingress functions."""
|
||||
api_ingress = APIIngress()
|
||||
@@ -380,30 +470,28 @@ class RestAPI(CoreSysAttributes):
|
||||
]
|
||||
)
|
||||
|
||||
def _register_snapshots(self) -> None:
|
||||
"""Register snapshots functions."""
|
||||
api_snapshots = APISnapshots()
|
||||
api_snapshots.coresys = self.coresys
|
||||
def _register_backups(self) -> None:
|
||||
"""Register backups functions."""
|
||||
api_backups = APIBackups()
|
||||
api_backups.coresys = self.coresys
|
||||
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
web.get("/snapshots", api_snapshots.list),
|
||||
web.post("/snapshots/reload", api_snapshots.reload),
|
||||
web.post("/snapshots/new/full", api_snapshots.snapshot_full),
|
||||
web.post("/snapshots/new/partial", api_snapshots.snapshot_partial),
|
||||
web.post("/snapshots/new/upload", api_snapshots.upload),
|
||||
web.get("/snapshots/{snapshot}/info", api_snapshots.info),
|
||||
web.delete("/snapshots/{snapshot}", api_snapshots.remove),
|
||||
web.get("/backups", api_backups.list),
|
||||
web.get("/backups/info", api_backups.info),
|
||||
web.post("/backups/options", api_backups.options),
|
||||
web.post("/backups/reload", api_backups.reload),
|
||||
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(
|
||||
"/snapshots/{snapshot}/restore/full", api_snapshots.restore_full
|
||||
"/backups/{slug}/restore/partial",
|
||||
api_backups.restore_partial,
|
||||
),
|
||||
web.post(
|
||||
"/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),
|
||||
web.get("/backups/{slug}/download", api_backups.download),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -456,6 +544,8 @@ class RestAPI(CoreSysAttributes):
|
||||
"""Register Audio functions."""
|
||||
api_audio = APIAudio()
|
||||
api_audio.coresys = self.coresys
|
||||
api_host = APIHost()
|
||||
api_host.coresys = self.coresys
|
||||
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
@@ -485,6 +575,15 @@ class RestAPI(CoreSysAttributes):
|
||||
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
|
||||
),
|
||||
@@ -503,14 +602,26 @@ class RestAPI(CoreSysAttributes):
|
||||
"/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,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
@@ -1,7 +1,8 @@
|
||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
from typing import Any, Awaitable, Dict, List
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -52,13 +53,11 @@ from ..const import (
|
||||
ATTR_INGRESS_PANEL,
|
||||
ATTR_INGRESS_PORT,
|
||||
ATTR_INGRESS_URL,
|
||||
ATTR_INSTALLED,
|
||||
ATTR_IP_ADDRESS,
|
||||
ATTR_KERNEL_MODULES,
|
||||
ATTR_LOGO,
|
||||
ATTR_LONG_DESCRIPTION,
|
||||
ATTR_MACHINE,
|
||||
ATTR_MAINTAINER,
|
||||
ATTR_MEMORY_LIMIT,
|
||||
ATTR_MEMORY_PERCENT,
|
||||
ATTR_MEMORY_USAGE,
|
||||
@@ -71,13 +70,12 @@ from ..const import (
|
||||
ATTR_OPTIONS,
|
||||
ATTR_PRIVILEGED,
|
||||
ATTR_PROTECTED,
|
||||
ATTR_PWNED,
|
||||
ATTR_RATING,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_REPOSITORY,
|
||||
ATTR_SCHEMA,
|
||||
ATTR_SERVICES,
|
||||
ATTR_SLUG,
|
||||
ATTR_SOURCE,
|
||||
ATTR_STAGE,
|
||||
ATTR_STARTUP,
|
||||
ATTR_STATE,
|
||||
@@ -94,23 +92,25 @@ from ..const import (
|
||||
ATTR_VIDEO,
|
||||
ATTR_WATCHDOG,
|
||||
ATTR_WEBUI,
|
||||
CONTENT_TYPE_BINARY,
|
||||
CONTENT_TYPE_PNG,
|
||||
CONTENT_TYPE_TEXT,
|
||||
REQUEST_FROM,
|
||||
AddonBoot,
|
||||
AddonState,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..docker.stats import DockerStats
|
||||
from ..exceptions import APIError, APIForbidden, PwnedError, PwnedSecret
|
||||
from ..utils.pwned import check_pwned_password
|
||||
from ..exceptions import (
|
||||
APIAddonNotInstalled,
|
||||
APIError,
|
||||
APIForbidden,
|
||||
PwnedError,
|
||||
PwnedSecret,
|
||||
)
|
||||
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__)
|
||||
|
||||
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
|
||||
SCHEMA_OPTIONS = vol.Schema(
|
||||
@@ -118,8 +118,8 @@ SCHEMA_OPTIONS = vol.Schema(
|
||||
vol.Optional(ATTR_BOOT): vol.Coerce(AddonBoot),
|
||||
vol.Optional(ATTR_NETWORK): vol.Maybe(docker_ports),
|
||||
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_INGRESS_PANEL): vol.Boolean(),
|
||||
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
||||
}
|
||||
@@ -132,7 +132,7 @@ SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
|
||||
class APIAddons(CoreSysAttributes):
|
||||
"""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."""
|
||||
addon_slug: str = request.match_info.get("addon")
|
||||
|
||||
@@ -146,17 +146,13 @@ class APIAddons(CoreSysAttributes):
|
||||
addon = self.sys_addons.get(addon_slug)
|
||||
if not addon:
|
||||
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:
|
||||
raise APIError("Addon is not installed")
|
||||
raise APIAddonNotInstalled("Addon is not installed")
|
||||
|
||||
return addon
|
||||
|
||||
@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."""
|
||||
data_addons = [
|
||||
{
|
||||
@@ -165,43 +161,30 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_DESCRIPTON: addon.description,
|
||||
ATTR_ADVANCED: addon.advanced,
|
||||
ATTR_STAGE: addon.stage,
|
||||
ATTR_VERSION: addon.version if addon.is_installed else None,
|
||||
ATTR_VERSION: addon.version,
|
||||
ATTR_VERSION_LATEST: addon.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: addon.need_update
|
||||
if addon.is_installed
|
||||
else False,
|
||||
ATTR_INSTALLED: addon.is_installed,
|
||||
ATTR_UPDATE_AVAILABLE: addon.need_update,
|
||||
ATTR_AVAILABLE: addon.available,
|
||||
ATTR_DETACHED: addon.is_detached,
|
||||
ATTR_HOMEASSISTANT: addon.homeassistant_version,
|
||||
ATTR_STATE: addon.state,
|
||||
ATTR_REPOSITORY: addon.repository,
|
||||
ATTR_BUILD: addon.need_build,
|
||||
ATTR_URL: addon.url,
|
||||
ATTR_ICON: addon.with_icon,
|
||||
ATTR_LOGO: addon.with_logo,
|
||||
}
|
||||
for addon in self.sys_addons.all
|
||||
for addon in self.sys_addons.installed
|
||||
]
|
||||
|
||||
data_repositories = [
|
||||
{
|
||||
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}
|
||||
return {ATTR_ADDONS: data_addons}
|
||||
|
||||
@api_process
|
||||
async def reload(self, request: web.Request) -> None:
|
||||
"""Reload all add-on data from store."""
|
||||
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."""
|
||||
addon: AnyAddon = self._extract_addon(request)
|
||||
|
||||
@@ -214,11 +197,8 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_LONG_DESCRIPTION: addon.long_description,
|
||||
ATTR_ADVANCED: addon.advanced,
|
||||
ATTR_STAGE: addon.stage,
|
||||
ATTR_AUTO_UPDATE: None,
|
||||
ATTR_REPOSITORY: addon.repository,
|
||||
ATTR_VERSION: None,
|
||||
ATTR_VERSION_LATEST: addon.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: False,
|
||||
ATTR_PROTECTED: addon.protected,
|
||||
ATTR_RATING: rating_security(addon),
|
||||
ATTR_BOOT: addon.boot,
|
||||
@@ -228,7 +208,6 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_MACHINE: addon.supported_machine,
|
||||
ATTR_HOMEASSISTANT: addon.homeassistant_version,
|
||||
ATTR_URL: addon.url,
|
||||
ATTR_STATE: AddonState.UNKNOWN,
|
||||
ATTR_DETACHED: addon.is_detached,
|
||||
ATTR_AVAILABLE: addon.available,
|
||||
ATTR_BUILD: addon.need_build,
|
||||
@@ -241,13 +220,11 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_PRIVILEGED: addon.privileged,
|
||||
ATTR_FULL_ACCESS: addon.with_full_access,
|
||||
ATTR_APPARMOR: addon.apparmor,
|
||||
ATTR_DEVICES: addon.static_devices,
|
||||
ATTR_ICON: addon.with_icon,
|
||||
ATTR_LOGO: addon.with_logo,
|
||||
ATTR_CHANGELOG: addon.with_changelog,
|
||||
ATTR_DOCUMENTATION: addon.with_documentation,
|
||||
ATTR_STDIN: addon.with_stdin,
|
||||
ATTR_WEBUI: None,
|
||||
ATTR_HASSIO_API: addon.access_hassio_api,
|
||||
ATTR_HASSIO_ROLE: addon.hassio_role,
|
||||
ATTR_AUTH_API: addon.access_auth_api,
|
||||
@@ -261,55 +238,42 @@ class APIAddons(CoreSysAttributes):
|
||||
ATTR_DOCKER_API: addon.access_docker_api,
|
||||
ATTR_VIDEO: addon.with_video,
|
||||
ATTR_AUDIO: addon.with_audio,
|
||||
ATTR_AUDIO_INPUT: None,
|
||||
ATTR_AUDIO_OUTPUT: None,
|
||||
ATTR_STARTUP: addon.startup,
|
||||
ATTR_SERVICES: _pretty_services(addon),
|
||||
ATTR_DISCOVERY: addon.discovery,
|
||||
ATTR_IP_ADDRESS: None,
|
||||
ATTR_TRANSLATIONS: addon.translations,
|
||||
ATTR_INGRESS: addon.with_ingress,
|
||||
ATTR_INGRESS_ENTRY: None,
|
||||
ATTR_INGRESS_URL: None,
|
||||
ATTR_INGRESS_PORT: None,
|
||||
ATTR_INGRESS_PANEL: None,
|
||||
ATTR_WATCHDOG: None,
|
||||
ATTR_SIGNED: addon.signed,
|
||||
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_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_UPDATE_AVAILABLE: addon.need_update,
|
||||
ATTR_WATCHDOG: addon.watchdog,
|
||||
ATTR_DEVICES: addon.static_devices
|
||||
+ [device.path for device in addon.devices],
|
||||
}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@api_process
|
||||
async def options(self, request: web.Request) -> None:
|
||||
"""Store user options for add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
addon = self._extract_addon(request)
|
||||
|
||||
# Update secrets for validation
|
||||
await self.sys_homeassistant.secrets.reload()
|
||||
|
||||
# Extend schema with add-on specific validation
|
||||
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
|
||||
@@ -337,31 +301,39 @@ class APIAddons(CoreSysAttributes):
|
||||
@api_process
|
||||
async def options_validate(self, request: web.Request) -> None:
|
||||
"""Validate user options for add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
data = {ATTR_MESSAGE: "", ATTR_VALID: True}
|
||||
addon = self._extract_addon(request)
|
||||
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:
|
||||
addon.schema(addon.options)
|
||||
options_schema.validate(options)
|
||||
except vol.Invalid as ex:
|
||||
data[ATTR_MESSAGE] = humanize_error(addon.options, ex)
|
||||
data[ATTR_MESSAGE] = humanize_error(options, ex)
|
||||
data[ATTR_VALID] = False
|
||||
|
||||
# Validate security
|
||||
if self.sys_config.force_security:
|
||||
for secret in addon.pwned:
|
||||
try:
|
||||
await check_pwned_password(self.sys_websession, secret)
|
||||
continue
|
||||
except PwnedSecret:
|
||||
data[ATTR_MESSAGE] = "Add-on use pwned secrets!"
|
||||
except PwnedError as err:
|
||||
data[
|
||||
ATTR_MESSAGE
|
||||
] = f"Error happening on pwned secrets check: {err!s}!"
|
||||
if not self.sys_security.pwned:
|
||||
return data
|
||||
|
||||
data[ATTR_VALID] = False
|
||||
break
|
||||
# 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
|
||||
|
||||
@@ -371,18 +343,20 @@ class APIAddons(CoreSysAttributes):
|
||||
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)
|
||||
|
||||
addon = self._extract_addon_installed(request)
|
||||
# Lookup/reload secrets
|
||||
await self.sys_homeassistant.secrets.reload()
|
||||
try:
|
||||
return addon.schema(addon.options)
|
||||
return addon.schema.validate(addon.options)
|
||||
except vol.Invalid:
|
||||
raise APIError("Invalid configuration data for the add-on") from None
|
||||
|
||||
@api_process
|
||||
async def security(self, request: web.Request) -> None:
|
||||
"""Store security options for add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
body: Dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
|
||||
addon = self._extract_addon(request)
|
||||
body: dict[str, Any] = await api_validate(SCHEMA_SECURITY, request)
|
||||
|
||||
if ATTR_PROTECTED in body:
|
||||
_LOGGER.warning("Changing protected flag for %s!", addon.slug)
|
||||
@@ -391,9 +365,9 @@ class APIAddons(CoreSysAttributes):
|
||||
addon.save_persist()
|
||||
|
||||
@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."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
addon = self._extract_addon(request)
|
||||
|
||||
stats: DockerStats = await addon.stats()
|
||||
|
||||
@@ -411,83 +385,43 @@ class APIAddons(CoreSysAttributes):
|
||||
@api_process
|
||||
def uninstall(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Uninstall add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
addon = self._extract_addon(request)
|
||||
return asyncio.shield(addon.uninstall())
|
||||
|
||||
@api_process
|
||||
def start(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Start add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
addon = self._extract_addon(request)
|
||||
return asyncio.shield(addon.start())
|
||||
|
||||
@api_process
|
||||
def stop(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Stop add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
addon = self._extract_addon(request)
|
||||
return asyncio.shield(addon.stop())
|
||||
|
||||
@api_process
|
||||
def restart(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Restart add-on."""
|
||||
addon: Addon = self._extract_addon_installed(request)
|
||||
addon: Addon = self._extract_addon(request)
|
||||
return asyncio.shield(addon.restart())
|
||||
|
||||
@api_process
|
||||
def rebuild(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Rebuild local build add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
addon = self._extract_addon(request)
|
||||
return asyncio.shield(addon.rebuild())
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
||||
"""Return logs from add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
addon = self._extract_addon(request)
|
||||
return addon.logs()
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_PNG)
|
||||
async def icon(self, request: web.Request) -> bytes:
|
||||
"""Return icon from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_icon:
|
||||
raise APIError(f"No icon found for add-on {addon.slug}!")
|
||||
|
||||
with addon.path_icon.open("rb") as png:
|
||||
return png.read()
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_PNG)
|
||||
async def logo(self, request: web.Request) -> bytes:
|
||||
"""Return logo from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_logo:
|
||||
raise APIError(f"No logo found for add-on {addon.slug}!")
|
||||
|
||||
with addon.path_logo.open("rb") as png:
|
||||
return png.read()
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_TEXT)
|
||||
async def changelog(self, request: web.Request) -> str:
|
||||
"""Return changelog from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_changelog:
|
||||
raise APIError(f"No changelog found for add-on {addon.slug}!")
|
||||
|
||||
with addon.path_changelog.open("r") as changelog:
|
||||
return changelog.read()
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_TEXT)
|
||||
async def documentation(self, request: web.Request) -> str:
|
||||
"""Return documentation from add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_documentation:
|
||||
raise APIError(f"No documentation found for add-on {addon.slug}!")
|
||||
|
||||
with addon.path_documentation.open("r") as documentation:
|
||||
return documentation.read()
|
||||
|
||||
@api_process
|
||||
async def stdin(self, request: web.Request) -> None:
|
||||
"""Write to stdin of add-on."""
|
||||
addon = self._extract_addon_installed(request)
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.with_stdin:
|
||||
raise APIError(f"STDIN not supported the {addon.slug} add-on")
|
||||
|
||||
@@ -495,6 +429,6 @@ class APIAddons(CoreSysAttributes):
|
||||
await asyncio.shield(addon.write_stdin(data))
|
||||
|
||||
|
||||
def _pretty_services(addon: AnyAddon) -> List[str]:
|
||||
def _pretty_services(addon: Addon) -> list[str]:
|
||||
"""Return a simplified services role list."""
|
||||
return [f"{name}:{access}" for name, access in addon.services_role.items()]
|
||||
|
@@ -1,7 +1,8 @@
|
||||
"""Init file for Supervisor Audio RESTful API."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
from typing import Any, Awaitable, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import attr
|
||||
@@ -29,12 +30,12 @@ from ..const import (
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
ATTR_VOLUME,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
from ..host.sound import StreamType
|
||||
from ..validate import version_tag
|
||||
from .const import CONTENT_TYPE_BINARY
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -56,10 +57,10 @@ SCHEMA_MUTE = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
|
||||
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): str})
|
||||
|
||||
SCHEMA_PROFILE = vol.Schema(
|
||||
{vol.Required(ATTR_CARD): vol.Coerce(str), vol.Required(ATTR_NAME): vol.Coerce(str)}
|
||||
{vol.Required(ATTR_CARD): str, vol.Required(ATTR_NAME): str}
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +68,7 @@ class APIAudio(CoreSysAttributes):
|
||||
"""Handle RESTful API for Audio functions."""
|
||||
|
||||
@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 {
|
||||
ATTR_VERSION: self.sys_plugins.audio.version,
|
||||
@@ -89,7 +90,7 @@ class APIAudio(CoreSysAttributes):
|
||||
}
|
||||
|
||||
@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."""
|
||||
stats = await self.sys_plugins.audio.stats()
|
||||
|
||||
|
@@ -1,7 +1,6 @@
|
||||
"""Init file for Supervisor auth/SSO RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from aiohttp import BasicAuth, web
|
||||
from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE
|
||||
@@ -9,27 +8,22 @@ from aiohttp.web_exceptions import HTTPUnauthorized
|
||||
import voluptuous as vol
|
||||
|
||||
from ..addons.addon import Addon
|
||||
from ..const import (
|
||||
ATTR_PASSWORD,
|
||||
ATTR_USERNAME,
|
||||
CONTENT_TYPE_JSON,
|
||||
CONTENT_TYPE_URL,
|
||||
REQUEST_FROM,
|
||||
)
|
||||
from ..const import ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIForbidden
|
||||
from .const import CONTENT_TYPE_JSON, CONTENT_TYPE_URL
|
||||
from .utils import api_process, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_PASSWORD_RESET = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_USERNAME): vol.Coerce(str),
|
||||
vol.Required(ATTR_PASSWORD): vol.Coerce(str),
|
||||
vol.Required(ATTR_USERNAME): str,
|
||||
vol.Required(ATTR_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
REALM_HEADER: Dict[str, str] = {
|
||||
REALM_HEADER: dict[str, str] = {
|
||||
WWW_AUTHENTICATE: 'Basic realm="Home Assistant Authentication"'
|
||||
}
|
||||
|
||||
@@ -46,7 +40,7 @@ class APIAuth(CoreSysAttributes):
|
||||
return self.sys_auth.check_login(addon, auth.login, auth.password)
|
||||
|
||||
def _process_dict(
|
||||
self, request: web.Request, addon: Addon, data: Dict[str, str]
|
||||
self, request: web.Request, addon: Addon, data: dict[str, str]
|
||||
) -> bool:
|
||||
"""Process login with dict data.
|
||||
|
||||
@@ -86,7 +80,7 @@ class APIAuth(CoreSysAttributes):
|
||||
@api_process
|
||||
async def reset(self, request: web.Request) -> None:
|
||||
"""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(
|
||||
self.sys_auth.change_password(body[ATTR_USERNAME], body[ATTR_PASSWORD])
|
||||
)
|
||||
|
256
supervisor/api/backups.py
Normal file
256
supervisor/api/backups.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Backups RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
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_NAME,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_PROTECTED,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_SIZE,
|
||||
ATTR_SLUG,
|
||||
ATTR_SUPERVISOR_VERSION,
|
||||
ATTR_TYPE,
|
||||
ATTR_VERSION,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
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()),
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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_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, request):
|
||||
"""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_ADDONS: data_addons,
|
||||
ATTR_REPOSITORIES: backup.repositories,
|
||||
ATTR_FOLDERS: backup.folders,
|
||||
}
|
||||
|
||||
@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(**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(**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 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:
|
||||
_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."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -32,7 +32,7 @@ class APICli(CoreSysAttributes):
|
||||
"""Handle RESTful API for HA Cli functions."""
|
||||
|
||||
@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 {
|
||||
ATTR_VERSION: self.sys_plugins.cli.version,
|
||||
@@ -41,7 +41,7 @@ class APICli(CoreSysAttributes):
|
||||
}
|
||||
|
||||
@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."""
|
||||
stats = await self.sys_plugins.cli.stats()
|
||||
|
||||
|
41
supervisor/api/const.py
Normal file
41
supervisor/api/const.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""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_APPARMOR_VERSION = "apparmor_version"
|
||||
ATTR_AGENT_VERSION = "agent_version"
|
||||
ATTR_AVAILABLE_UPDATES = "available_updates"
|
||||
ATTR_BOOT_TIMESTAMP = "boot_timestamp"
|
||||
ATTR_BOOTS = "boots"
|
||||
ATTR_BROADCAST_LLMNR = "broadcast_llmnr"
|
||||
ATTR_BROADCAST_MDNS = "broadcast_mdns"
|
||||
ATTR_DATA_DISK = "data_disk"
|
||||
ATTR_DEVICE = "device"
|
||||
ATTR_DISK_LED = "disk_led"
|
||||
ATTR_DT_SYNCHRONIZED = "dt_synchronized"
|
||||
ATTR_DT_UTC = "dt_utc"
|
||||
ATTR_FALLBACK = "fallback"
|
||||
ATTR_HEARTBEAT_LED = "heartbeat_led"
|
||||
ATTR_IDENTIFIERS = "identifiers"
|
||||
ATTR_LLMNR = "llmnr"
|
||||
ATTR_LLMNR_HOSTNAME = "llmnr_hostname"
|
||||
ATTR_MDNS = "mdns"
|
||||
ATTR_PANEL_PATH = "panel_path"
|
||||
ATTR_POWER_LED = "power_led"
|
||||
ATTR_SIGNED = "signed"
|
||||
ATTR_STARTUP_TIME = "startup_time"
|
||||
ATTR_UPDATE_TYPE = "update_type"
|
||||
ATTR_USE_NTP = "use_ntp"
|
||||
ATTR_BY_ID = "by_id"
|
||||
ATTR_SUBSYSTEM = "subsystem"
|
||||
ATTR_SYSFS = "sysfs"
|
||||
ATTR_DEV_PATH = "dev_path"
|
||||
ATTR_ATTRIBUTES = "attributes"
|
||||
ATTR_CHILDREN = "children"
|
@@ -1,7 +1,8 @@
|
||||
"""Init file for Supervisor DNS RESTful API."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
from typing import Any, Awaitable, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -21,17 +22,22 @@ from ..const import (
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
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
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
# 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})
|
||||
|
||||
@@ -40,7 +46,7 @@ class APICoreDNS(CoreSysAttributes):
|
||||
"""Handle RESTful API for DNS functions."""
|
||||
|
||||
@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 {
|
||||
ATTR_VERSION: self.sys_plugins.dns.version,
|
||||
@@ -49,21 +55,32 @@ class APICoreDNS(CoreSysAttributes):
|
||||
ATTR_HOST: str(self.sys_docker.network.dns),
|
||||
ATTR_SERVERS: self.sys_plugins.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
|
||||
async def options(self, request: web.Request) -> None:
|
||||
"""Set DNS options."""
|
||||
body = await api_validate(SCHEMA_OPTIONS, request)
|
||||
restart_required = False
|
||||
|
||||
if ATTR_SERVERS in body:
|
||||
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_plugins.dns.save_data()
|
||||
|
||||
@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."""
|
||||
stats = await self.sys_plugins.dns.stats()
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -21,7 +21,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_DOCKER_REGISTRY = vol.Schema(
|
||||
{
|
||||
vol.Coerce(str): {
|
||||
str: {
|
||||
vol.Required(ATTR_USERNAME): str,
|
||||
vol.Required(ATTR_PASSWORD): str,
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class APIDocker(CoreSysAttributes):
|
||||
"""Handle RESTful API for Docker configuration."""
|
||||
|
||||
@api_process
|
||||
async def registries(self, request) -> Dict[str, Any]:
|
||||
async def registries(self, request) -> dict[str, Any]:
|
||||
"""Return the list of registries."""
|
||||
data_registries = {}
|
||||
for hostname, registry in self.sys_docker.config.registries.items():
|
||||
|
@@ -1,25 +1,26 @@
|
||||
"""Init file for Supervisor hardware RESTful API."""
|
||||
import logging
|
||||
from typing import Any, Awaitable, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..const import ATTR_AUDIO, ATTR_DEVICES, ATTR_INPUT, ATTR_NAME, ATTR_OUTPUT
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..hardware.const import (
|
||||
from ..hardware.data import Device
|
||||
from .const import (
|
||||
ATTR_ATTRIBUTES,
|
||||
ATTR_BY_ID,
|
||||
ATTR_CHILDREN,
|
||||
ATTR_DEV_PATH,
|
||||
ATTR_SUBSYSTEM,
|
||||
ATTR_SYSFS,
|
||||
)
|
||||
from ..hardware.data import Device
|
||||
from .utils import api_process
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def device_struct(device: Device) -> Dict[str, Any]:
|
||||
def device_struct(device: Device) -> dict[str, Any]:
|
||||
"""Return a dict with information of a interface to be used in th API."""
|
||||
return {
|
||||
ATTR_NAME: device.name,
|
||||
@@ -28,6 +29,7 @@ def device_struct(device: Device) -> Dict[str, Any]:
|
||||
ATTR_SUBSYSTEM: device.subsystem,
|
||||
ATTR_BY_ID: device.by_id,
|
||||
ATTR_ATTRIBUTES: device.attributes,
|
||||
ATTR_CHILDREN: device.children,
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +37,7 @@ class APIHardware(CoreSysAttributes):
|
||||
"""Handle RESTful API for hardware functions."""
|
||||
|
||||
@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."""
|
||||
return {
|
||||
ATTR_DEVICES: [
|
||||
@@ -44,7 +46,7 @@ class APIHardware(CoreSysAttributes):
|
||||
}
|
||||
|
||||
@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."""
|
||||
return {
|
||||
ATTR_AUDIO: {
|
||||
@@ -58,8 +60,3 @@ class APIHardware(CoreSysAttributes):
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def trigger(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Trigger a udev device reload."""
|
||||
_LOGGER.debug("Ignoring DEPRECATED hardware trigger function call.")
|
||||
|
@@ -1,7 +1,8 @@
|
||||
"""Init file for Supervisor Home Assistant RESTful API."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
from typing import Any, Awaitable, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -10,6 +11,7 @@ from ..const import (
|
||||
ATTR_ARCH,
|
||||
ATTR_AUDIO_INPUT,
|
||||
ATTR_AUDIO_OUTPUT,
|
||||
ATTR_BACKUP,
|
||||
ATTR_BLK_READ,
|
||||
ATTR_BLK_WRITE,
|
||||
ATTR_BOOT,
|
||||
@@ -28,13 +30,12 @@ from ..const import (
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
ATTR_WAIT_BOOT,
|
||||
ATTR_WATCHDOG,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
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
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -43,25 +44,29 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
SCHEMA_OPTIONS = vol.Schema(
|
||||
{
|
||||
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_SSL): 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(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(vol.Coerce(str)),
|
||||
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
|
||||
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
|
||||
}
|
||||
)
|
||||
|
||||
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):
|
||||
"""Handle RESTful API for Home Assistant functions."""
|
||||
|
||||
@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 {
|
||||
ATTR_VERSION: self.sys_homeassistant.version,
|
||||
@@ -75,11 +80,8 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
ATTR_PORT: self.sys_homeassistant.api_port,
|
||||
ATTR_SSL: self.sys_homeassistant.api_ssl,
|
||||
ATTR_WATCHDOG: self.sys_homeassistant.watchdog,
|
||||
ATTR_WAIT_BOOT: self.sys_homeassistant.wait_boot,
|
||||
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
|
||||
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
|
||||
# Remove end of Q3 2020
|
||||
"last_version": self.sys_homeassistant.latest_version,
|
||||
}
|
||||
|
||||
@api_process
|
||||
@@ -102,9 +104,6 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
if ATTR_WATCHDOG in body:
|
||||
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:
|
||||
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
|
||||
|
||||
@@ -117,7 +116,7 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
self.sys_homeassistant.save_data()
|
||||
|
||||
@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."""
|
||||
stats = await self.sys_homeassistant.core.stats()
|
||||
if not stats:
|
||||
@@ -137,10 +136,14 @@ class APIHomeAssistant(CoreSysAttributes):
|
||||
@api_process
|
||||
async def update(self, request: web.Request) -> None:
|
||||
"""Update Home Assistant."""
|
||||
body = await api_validate(SCHEMA_VERSION, request)
|
||||
version = body.get(ATTR_VERSION, self.sys_homeassistant.latest_version)
|
||||
body = await api_validate(SCHEMA_UPDATE, request)
|
||||
|
||||
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
|
||||
def stop(self, request: web.Request) -> Awaitable[None]:
|
||||
|
@@ -1,9 +1,12 @@
|
||||
"""Init file for Supervisor host RESTful API."""
|
||||
import asyncio
|
||||
from typing import Awaitable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.hdrs import ACCEPT, RANGE
|
||||
import voluptuous as vol
|
||||
from voluptuous.error import CoerceInvalid
|
||||
|
||||
from ..const import (
|
||||
ATTR_CHASSIS,
|
||||
@@ -21,14 +24,35 @@ from ..const import (
|
||||
ATTR_OPERATING_SYSTEM,
|
||||
ATTR_SERVICES,
|
||||
ATTR_STATE,
|
||||
CONTENT_TYPE_BINARY,
|
||||
ATTR_TIMEZONE,
|
||||
)
|
||||
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):
|
||||
@@ -38,6 +62,8 @@ class APIHost(CoreSysAttributes):
|
||||
async def info(self, request):
|
||||
"""Return host information."""
|
||||
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_CPE: self.sys_host.info.cpe,
|
||||
ATTR_DEPLOYMENT: self.sys_host.info.deployment,
|
||||
@@ -47,8 +73,17 @@ class APIHost(CoreSysAttributes):
|
||||
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_LLMNR_HOSTNAME: self.sys_host.info.llmnr_hostname,
|
||||
ATTR_KERNEL: self.sys_host.info.kernel,
|
||||
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
|
||||
@@ -75,11 +110,7 @@ class APIHost(CoreSysAttributes):
|
||||
@api_process
|
||||
def reload(self, request):
|
||||
"""Reload host data."""
|
||||
return asyncio.shield(
|
||||
asyncio.wait(
|
||||
[self.sys_host.reload(), self.sys_resolution.evaluate.evaluate_system()]
|
||||
)
|
||||
)
|
||||
return asyncio.shield(self.sys_host.reload())
|
||||
|
||||
@api_process
|
||||
async def services(self, request):
|
||||
@@ -97,30 +128,75 @@ class APIHost(CoreSysAttributes):
|
||||
return {ATTR_SERVICES: services}
|
||||
|
||||
@api_process
|
||||
def service_start(self, request):
|
||||
"""Start a service."""
|
||||
unit = request.match_info.get(SERVICE)
|
||||
return asyncio.shield(self.sys_host.services.start(unit))
|
||||
async def list_boots(self, _: web.Request):
|
||||
"""Return a list of boot IDs."""
|
||||
boot_ids = await self.sys_host.logs.get_boot_ids()
|
||||
return {
|
||||
ATTR_BOOTS: {
|
||||
str(1 + i - len(boot_ids)): boot_id
|
||||
for i, boot_id in enumerate(boot_ids)
|
||||
}
|
||||
}
|
||||
|
||||
@api_process
|
||||
def service_stop(self, request):
|
||||
"""Stop a service."""
|
||||
unit = request.match_info.get(SERVICE)
|
||||
return asyncio.shield(self.sys_host.services.stop(unit))
|
||||
async def list_identifiers(self, _: web.Request):
|
||||
"""Return a list of syslog identifiers."""
|
||||
return {ATTR_IDENTIFIERS: await self.sys_host.logs.get_identifiers()}
|
||||
|
||||
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
|
||||
def service_reload(self, request):
|
||||
"""Reload a service."""
|
||||
unit = request.match_info.get(SERVICE)
|
||||
return asyncio.shield(self.sys_host.services.reload(unit))
|
||||
async def advanced_logs(
|
||||
self, request: web.Request, identifier: str | None = None, follow: bool = False
|
||||
) -> web.StreamResponse:
|
||||
"""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
|
||||
def service_restart(self, request):
|
||||
"""Restart a service."""
|
||||
unit = request.match_info.get(SERVICE)
|
||||
return asyncio.shield(self.sys_host.services.restart(unit))
|
||||
if BOOTID in request.match_info:
|
||||
params[PARAM_BOOT_ID] = await self._get_boot_id(
|
||||
request.match_info.get(BOOTID)
|
||||
)
|
||||
if follow:
|
||||
params[PARAM_FOLLOW] = ""
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
||||
def logs(self, request: web.Request) -> Awaitable[bytes]:
|
||||
"""Return host kernel logs."""
|
||||
return self.sys_host.info.get_dmesg()
|
||||
if ACCEPT in request.headers and request.headers[ACCEPT] not in [
|
||||
CONTENT_TYPE_TEXT,
|
||||
"*/*",
|
||||
]:
|
||||
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,52 +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_STATE,
|
||||
ATTR_SUPERVISOR,
|
||||
ATTR_SUPPORTED,
|
||||
ATTR_SUPPORTED_ARCH,
|
||||
ATTR_TIMEZONE,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from .utils import api_process
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APIInfo(CoreSysAttributes):
|
||||
"""Handle RESTful API for info functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Show system info."""
|
||||
return {
|
||||
ATTR_SUPERVISOR: self.sys_supervisor.version,
|
||||
ATTR_HOMEASSISTANT: self.sys_homeassistant.version,
|
||||
ATTR_HASSOS: self.sys_hassos.version,
|
||||
ATTR_DOCKER: self.sys_docker.info.version,
|
||||
ATTR_HOSTNAME: self.sys_host.info.hostname,
|
||||
ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system,
|
||||
ATTR_FEATURES: self.sys_host.features,
|
||||
ATTR_MACHINE: self.sys_machine,
|
||||
ATTR_ARCH: self.sys_arch.default,
|
||||
ATTR_STATE: self.sys_core.state,
|
||||
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,10 +2,10 @@
|
||||
import asyncio
|
||||
from ipaddress import ip_address
|
||||
import logging
|
||||
from typing import Any, Dict, Union
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import ClientTimeout, hdrs, web
|
||||
from aiohttp.web_exceptions import (
|
||||
HTTPBadGateway,
|
||||
HTTPServiceUnavailable,
|
||||
@@ -22,11 +22,11 @@ from ..const import (
|
||||
ATTR_PANELS,
|
||||
ATTR_SESSION,
|
||||
ATTR_TITLE,
|
||||
COOKIE_INGRESS,
|
||||
HEADER_TOKEN,
|
||||
HEADER_TOKEN_OLD,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from .const import COOKIE_INGRESS
|
||||
from .utils import api_process, api_validate, require_home_assistant
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -54,7 +54,7 @@ class APIIngress(CoreSysAttributes):
|
||||
return f"http://{addon.ip_address}:{addon.ingress_port}/{path}"
|
||||
|
||||
@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."""
|
||||
addons = {}
|
||||
for addon in self.sys_ingress.addons:
|
||||
@@ -69,14 +69,14 @@ class APIIngress(CoreSysAttributes):
|
||||
|
||||
@api_process
|
||||
@require_home_assistant
|
||||
async def create_session(self, request: web.Request) -> Dict[str, Any]:
|
||||
async def create_session(self, request: web.Request) -> dict[str, Any]:
|
||||
"""Create a new session."""
|
||||
session = self.sys_ingress.create_session()
|
||||
return {ATTR_SESSION: session}
|
||||
|
||||
@api_process
|
||||
@require_home_assistant
|
||||
async def validate_session(self, request: web.Request) -> Dict[str, Any]:
|
||||
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)
|
||||
|
||||
@@ -88,7 +88,7 @@ class APIIngress(CoreSysAttributes):
|
||||
@require_home_assistant
|
||||
async def handler(
|
||||
self, request: web.Request
|
||||
) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
|
||||
) -> web.Response | web.StreamResponse | web.WebSocketResponse:
|
||||
"""Route data to Supervisor ingress service."""
|
||||
|
||||
# Check Ingress Session
|
||||
@@ -159,12 +159,21 @@ class APIIngress(CoreSysAttributes):
|
||||
|
||||
async def _handle_request(
|
||||
self, request: web.Request, addon: Addon, path: str
|
||||
) -> Union[web.Response, web.StreamResponse]:
|
||||
) -> web.Response | web.StreamResponse:
|
||||
"""Ingress route for request."""
|
||||
url = self._create_url(addon, path)
|
||||
data = await request.read()
|
||||
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(
|
||||
request.method,
|
||||
url,
|
||||
@@ -172,6 +181,7 @@ class APIIngress(CoreSysAttributes):
|
||||
params=request.query,
|
||||
allow_redirects=False,
|
||||
data=data,
|
||||
timeout=ClientTimeout(total=None),
|
||||
) as result:
|
||||
headers = _response_header(result)
|
||||
|
||||
@@ -208,9 +218,7 @@ class APIIngress(CoreSysAttributes):
|
||||
return response
|
||||
|
||||
|
||||
def _init_header(
|
||||
request: web.Request, addon: str
|
||||
) -> Union[CIMultiDict, Dict[str, str]]:
|
||||
def _init_header(request: web.Request, addon: str) -> CIMultiDict | dict[str, str]:
|
||||
"""Create initial header."""
|
||||
headers = {}
|
||||
|
||||
@@ -219,6 +227,7 @@ def _init_header(
|
||||
if name in (
|
||||
hdrs.CONTENT_LENGTH,
|
||||
hdrs.CONTENT_ENCODING,
|
||||
hdrs.TRANSFER_ENCODING,
|
||||
hdrs.SEC_WEBSOCKET_EXTENSIONS,
|
||||
hdrs.SEC_WEBSOCKET_PROTOCOL,
|
||||
hdrs.SEC_WEBSOCKET_VERSION,
|
||||
@@ -237,7 +246,7 @@ def _init_header(
|
||||
return headers
|
||||
|
||||
|
||||
def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
|
||||
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
|
||||
"""Create response header."""
|
||||
headers = {}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""Init file for Supervisor Jobs RESTful API."""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -20,7 +20,7 @@ class APIJobs(CoreSysAttributes):
|
||||
"""Handle RESTful API for OS functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
"""Return JobManager information."""
|
||||
return {
|
||||
ATTR_IGNORE_CONDITIONS: self.sys_jobs.ignore_conditions,
|
||||
|
1
supervisor/api/middleware/__init__.py
Normal file
1
supervisor/api/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API middleware for aiohttp."""
|
207
supervisor/api/middleware/security.py
Normal file
207
supervisor/api/middleware/security.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Handle security part of this API."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aiohttp.web import Request, RequestHandler, Response, middleware
|
||||
from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized
|
||||
|
||||
from ...const import (
|
||||
REQUEST_FROM,
|
||||
ROLE_ADMIN,
|
||||
ROLE_BACKUP,
|
||||
ROLE_DEFAULT,
|
||||
ROLE_HOMEASSISTANT,
|
||||
ROLE_MANAGER,
|
||||
CoreState,
|
||||
)
|
||||
from ...coresys import CoreSys, CoreSysAttributes
|
||||
from ..utils import api_return_error, excract_supervisor_token
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
# fmt: off
|
||||
|
||||
# Block Anytime
|
||||
BLACKLIST = re.compile(
|
||||
r"^(?:"
|
||||
r"|/homeassistant/api/hassio/.*"
|
||||
r"|/core/api/hassio/.*"
|
||||
r")$"
|
||||
)
|
||||
|
||||
# Free to call or have own security concepts
|
||||
NO_SECURITY_CHECK = re.compile(
|
||||
r"^(?:"
|
||||
r"|/homeassistant/api/.*"
|
||||
r"|/homeassistant/websocket"
|
||||
r"|/core/api/.*"
|
||||
r"|/core/websocket"
|
||||
r"|/supervisor/ping"
|
||||
r")$"
|
||||
)
|
||||
|
||||
# Observer allow API calls
|
||||
OBSERVER_CHECK = re.compile(
|
||||
r"^(?:"
|
||||
r"|/.+/info"
|
||||
r")$"
|
||||
)
|
||||
|
||||
# Can called by every add-on
|
||||
ADDONS_API_BYPASS = 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 = {
|
||||
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(?:/[^/]+/(?!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".*"
|
||||
),
|
||||
}
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
||||
class SecurityMiddleware(CoreSysAttributes):
|
||||
"""Security middleware functions."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize security middleware."""
|
||||
self.coresys: CoreSys = coresys
|
||||
|
||||
@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.value}"
|
||||
)
|
||||
|
||||
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)
|
||||
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()
|
@@ -1,7 +1,8 @@
|
||||
"""Init file for Supervisor Multicast RESTful API."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
from typing import Any, Awaitable, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -18,11 +19,11 @@ from ..const import (
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
from ..validate import version_tag
|
||||
from .const import CONTENT_TYPE_BINARY
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -34,7 +35,7 @@ class APIMulticast(CoreSysAttributes):
|
||||
"""Handle RESTful API for Multicast functions."""
|
||||
|
||||
@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 {
|
||||
ATTR_VERSION: self.sys_plugins.multicast.version,
|
||||
@@ -43,7 +44,7 @@ class APIMulticast(CoreSysAttributes):
|
||||
}
|
||||
|
||||
@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."""
|
||||
stats = await self.sys_plugins.multicast.stats()
|
||||
|
||||
|
@@ -1,7 +1,8 @@
|
||||
"""REST API for network."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from ipaddress import ip_address, ip_interface
|
||||
from typing import Any, Awaitable, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import attr
|
||||
@@ -30,6 +31,7 @@ from ..const import (
|
||||
ATTR_PARENT,
|
||||
ATTR_PRIMARY,
|
||||
ATTR_PSK,
|
||||
ATTR_READY,
|
||||
ATTR_SIGNAL,
|
||||
ATTR_SSID,
|
||||
ATTR_SUPERVISOR_INTERNET,
|
||||
@@ -82,17 +84,18 @@ SCHEMA_UPDATE = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def ipconfig_struct(config: IpConfig) -> Dict[str, Any]:
|
||||
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]:
|
||||
def wifi_struct(config: WifiConfig) -> dict[str, Any]:
|
||||
"""Return a dict with information about wifi configuration."""
|
||||
return {
|
||||
ATTR_MODE: config.mode,
|
||||
@@ -102,7 +105,7 @@ def wifi_struct(config: WifiConfig) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def vlan_struct(config: VlanConfig) -> Dict[str, Any]:
|
||||
def vlan_struct(config: VlanConfig) -> dict[str, Any]:
|
||||
"""Return a dict with information about VLAN configuration."""
|
||||
return {
|
||||
ATTR_ID: config.id,
|
||||
@@ -110,7 +113,7 @@ def vlan_struct(config: VlanConfig) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def interface_struct(interface: Interface) -> Dict[str, Any]:
|
||||
def interface_struct(interface: Interface) -> dict[str, Any]:
|
||||
"""Return a dict with information of a interface to be used in th API."""
|
||||
return {
|
||||
ATTR_INTERFACE: interface.name,
|
||||
@@ -125,7 +128,7 @@ def interface_struct(interface: Interface) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def accesspoint_struct(accesspoint: AccessPoint) -> Dict[str, Any]:
|
||||
def accesspoint_struct(accesspoint: AccessPoint) -> dict[str, Any]:
|
||||
"""Return a dict for AccessPoint."""
|
||||
return {
|
||||
ATTR_MODE: accesspoint.mode,
|
||||
@@ -141,9 +144,7 @@ class APINetwork(CoreSysAttributes):
|
||||
|
||||
def _get_interface(self, name: str) -> Interface:
|
||||
"""Get Interface by name or default."""
|
||||
name = name.lower()
|
||||
|
||||
if name == "default":
|
||||
if name.lower() == "default":
|
||||
for interface in self.sys_host.network.interfaces:
|
||||
if not interface.primary:
|
||||
continue
|
||||
@@ -158,7 +159,7 @@ class APINetwork(CoreSysAttributes):
|
||||
raise APIError(f"Interface {name} does not exist") from None
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
async def info(self, request: web.Request) -> dict[str, Any]:
|
||||
"""Return network information."""
|
||||
return {
|
||||
ATTR_INTERFACES: [
|
||||
@@ -176,7 +177,7 @@ class APINetwork(CoreSysAttributes):
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def interface_info(self, request: web.Request) -> Dict[str, Any]:
|
||||
async def interface_info(self, request: web.Request) -> dict[str, Any]:
|
||||
"""Return network information for a interface."""
|
||||
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
|
||||
|
||||
@@ -196,12 +197,14 @@ class APINetwork(CoreSysAttributes):
|
||||
for key, config in body.items():
|
||||
if key == ATTR_IPV4:
|
||||
interface.ipv4 = attr.evolve(
|
||||
interface.ipv4 or IpConfig(InterfaceMethod.STATIC, [], None, []),
|
||||
interface.ipv4
|
||||
or IpConfig(InterfaceMethod.STATIC, [], None, [], None),
|
||||
**config,
|
||||
)
|
||||
elif key == ATTR_IPV6:
|
||||
interface.ipv6 = attr.evolve(
|
||||
interface.ipv6 or IpConfig(InterfaceMethod.STATIC, [], None, []),
|
||||
interface.ipv6
|
||||
or IpConfig(InterfaceMethod.STATIC, [], None, [], None),
|
||||
**config,
|
||||
)
|
||||
elif key == ATTR_WIFI:
|
||||
@@ -220,10 +223,12 @@ class APINetwork(CoreSysAttributes):
|
||||
@api_process
|
||||
def reload(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Reload network data."""
|
||||
return asyncio.shield(self.sys_host.network.update())
|
||||
return asyncio.shield(
|
||||
self.sys_host.network.update(force_connectivity_check=True)
|
||||
)
|
||||
|
||||
@api_process
|
||||
async def scan_accesspoints(self, request: web.Request) -> Dict[str, Any]:
|
||||
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))
|
||||
|
||||
@@ -257,6 +262,7 @@ class APINetwork(CoreSysAttributes):
|
||||
body[ATTR_IPV4].get(ATTR_ADDRESS, []),
|
||||
body[ATTR_IPV4].get(ATTR_GATEWAY, None),
|
||||
body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
|
||||
None,
|
||||
)
|
||||
|
||||
ipv6_config = None
|
||||
@@ -266,6 +272,7 @@ class APINetwork(CoreSysAttributes):
|
||||
body[ATTR_IPV6].get(ATTR_ADDRESS, []),
|
||||
body[ATTR_IPV6].get(ATTR_GATEWAY, None),
|
||||
body[ATTR_IPV6].get(ATTR_NAMESERVERS, []),
|
||||
None,
|
||||
)
|
||||
|
||||
vlan_interface = Interface(
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor Observer RESTful API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -33,7 +33,7 @@ class APIObserver(CoreSysAttributes):
|
||||
"""Handle RESTful API for Observer functions."""
|
||||
|
||||
@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 {
|
||||
ATTR_HOST: str(self.sys_docker.network.observer),
|
||||
@@ -43,7 +43,7 @@ class APIObserver(CoreSysAttributes):
|
||||
}
|
||||
|
||||
@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."""
|
||||
stats = await self.sys_plugins.observer.stats()
|
||||
|
||||
|
@@ -1,7 +1,9 @@
|
||||
"""Init file for Supervisor HassOS RESTful API."""
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
from typing import Any, Awaitable, Dict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
@@ -9,42 +11,116 @@ import voluptuous as vol
|
||||
from ..const import (
|
||||
ATTR_BOARD,
|
||||
ATTR_BOOT,
|
||||
ATTR_DEVICES,
|
||||
ATTR_UPDATE_AVAILABLE,
|
||||
ATTR_VERSION,
|
||||
ATTR_VERSION_LATEST,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import BoardInvalidError
|
||||
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||
from ..validate import version_tag
|
||||
from .const import (
|
||||
ATTR_DATA_DISK,
|
||||
ATTR_DEVICE,
|
||||
ATTR_DISK_LED,
|
||||
ATTR_HEARTBEAT_LED,
|
||||
ATTR_POWER_LED,
|
||||
)
|
||||
from .utils import api_process, api_validate
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag})
|
||||
SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): vol.All(str, vol.Coerce(Path))})
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class APIOS(CoreSysAttributes):
|
||||
"""Handle RESTful API for OS functions."""
|
||||
|
||||
@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 {
|
||||
ATTR_VERSION: self.sys_hassos.version,
|
||||
ATTR_VERSION_LATEST: self.sys_hassos.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: self.sys_hassos.need_update,
|
||||
ATTR_BOARD: self.sys_hassos.board,
|
||||
ATTR_VERSION: self.sys_os.version,
|
||||
ATTR_VERSION_LATEST: self.sys_os.latest_version,
|
||||
ATTR_UPDATE_AVAILABLE: self.sys_os.need_update,
|
||||
ATTR_BOARD: self.sys_os.board,
|
||||
ATTR_BOOT: self.sys_dbus.rauc.boot_slot,
|
||||
ATTR_DATA_DISK: self.sys_os.datadisk.disk_used,
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def update(self, request: web.Request) -> None:
|
||||
"""Update OS."""
|
||||
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
|
||||
def config_sync(self, request: web.Request) -> Awaitable[None]:
|
||||
"""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: self.sys_os.datadisk.available_disks,
|
||||
}
|
||||
|
||||
@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_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,16 @@
|
||||
|
||||
try {
|
||||
new Function("import('/api/hassio/app/frontend_latest/entrypoint.4050b348.js')")();
|
||||
} catch (err) {
|
||||
function loadES5() {
|
||||
var el = document.createElement('script');
|
||||
el.src = '/api/hassio/app/frontend_es5/entrypoint.bcf8e8ff.js';
|
||||
el.src = '/api/hassio/app/frontend_es5/entrypoint.4dd28383.js';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
if (/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent)) {
|
||||
loadES5();
|
||||
} else {
|
||||
try {
|
||||
new Function("import('/api/hassio/app/frontend_latest/entrypoint.962b867f.js')")();
|
||||
} catch (err) {
|
||||
loadES5();
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
1
supervisor/api/panel/frontend_es5/00c68234.js
Normal file
1
supervisor/api/panel/frontend_es5/00c68234.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/00c68234.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/00c68234.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/01f7f20e.js
Normal file
1
supervisor/api/panel/frontend_es5/01f7f20e.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/01f7f20e.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/01f7f20e.js.gz
Normal file
Binary file not shown.
2
supervisor/api/panel/frontend_es5/03402036.js
Normal file
2
supervisor/api/panel/frontend_es5/03402036.js
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
/*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT */
|
BIN
supervisor/api/panel/frontend_es5/03402036.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/03402036.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/05fc2212.js
Normal file
1
supervisor/api/panel/frontend_es5/05fc2212.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/05fc2212.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/05fc2212.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/06fd585a.js
Normal file
1
supervisor/api/panel/frontend_es5/06fd585a.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/06fd585a.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/06fd585a.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/085dd45b.js
Normal file
1
supervisor/api/panel/frontend_es5/085dd45b.js
Normal file
@@ -0,0 +1 @@
|
||||
!function(){"use strict";var r,t,e={5425:function(r,t,e){var n=e(93217);e(58556);function o(r,t){return function(r){if(Array.isArray(r))return r}(r)||function(r,t){var e=null==r?null:"undefined"!=typeof Symbol&&r[Symbol.iterator]||r["@@iterator"];if(null==e)return;var n,o,u=[],i=!0,a=!1;try{for(e=e.call(r);!(i=(n=e.next()).done)&&(u.push(n.value),!t||u.length!==t);i=!0);}catch(f){a=!0,o=f}finally{try{i||null==e.return||e.return()}finally{if(a)throw o}}return u}(r,t)||function(r,t){if(!r)return;if("string"==typeof r)return u(r,t);var e=Object.prototype.toString.call(r).slice(8,-1);"Object"===e&&r.constructor&&(e=r.constructor.name);if("Map"===e||"Set"===e)return Array.from(r);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return u(r,t)}(r,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function u(r,t){(null==t||t>r.length)&&(t=r.length);for(var e=0,n=new Array(t);e<t;e++)n[e]=r[e];return n}var i={filterData:function(r,t,e){return e=e.toUpperCase(),r.filter((function(r){return Object.entries(t).some((function(t){var n=o(t,2),u=n[0],i=n[1];return!(!i.filterable||!String(i.filterKey?r[i.valueColumn||u][i.filterKey]:r[i.valueColumn||u]).toUpperCase().includes(e))}))}))},sortData:function(r,t,e,n){return r.sort((function(r,o){var u=1;"desc"===e&&(u=-1);var i=t.filterKey?r[t.valueColumn||n][t.filterKey]:r[t.valueColumn||n],a=t.filterKey?o[t.valueColumn||n][t.filterKey]:o[t.valueColumn||n];return"string"==typeof i&&(i=i.toUpperCase()),"string"==typeof a&&(a=a.toUpperCase()),void 0===i&&void 0!==a?1:void 0===a&&void 0!==i?-1:i<a?-1*u:i>a?1*u:0}))}};(0,n.Jj)(i)}},n={};function o(r){var t=n[r];if(void 0!==t)return t.exports;var u=n[r]={exports:{}};return e[r](u,u.exports,o),u.exports}o.m=e,o.x=function(){var r=o.O(void 0,[9191],(function(){return o(5425)}));return r=o.O(r)},r=[],o.O=function(t,e,n,u){if(!e){var i=1/0;for(c=0;c<r.length;c++){e=r[c][0],n=r[c][1],u=r[c][2];for(var a=!0,f=0;f<e.length;f++)(!1&u||i>=u)&&Object.keys(o.O).every((function(r){return o.O[r](e[f])}))?e.splice(f--,1):(a=!1,u<i&&(i=u));if(a){r.splice(c--,1);var l=n();void 0!==l&&(t=l)}}return t}u=u||0;for(var c=r.length;c>0&&r[c-1][2]>u;c--)r[c]=r[c-1];r[c]=[e,n,u]},o.n=function(r){var t=r&&r.__esModule?function(){return r.default}:function(){return r};return o.d(t,{a:t}),t},o.d=function(r,t){for(var e in t)o.o(t,e)&&!o.o(r,e)&&Object.defineProperty(r,e,{enumerable:!0,get:t[e]})},o.f={},o.e=function(r){return Promise.all(Object.keys(o.f).reduce((function(t,e){return o.f[e](r,t),t}),[]))},o.u=function(r){return"ed6e5677.js"},o.o=function(r,t){return Object.prototype.hasOwnProperty.call(r,t)},o.p="/api/hassio/app/frontend_es5/",function(){var r={5425:1,5477:1};o.f.i=function(t,e){r[t]||importScripts(o.p+o.u(t))};var t=self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[],e=t.push.bind(t);t.push=function(t){var n=t[0],u=t[1],i=t[2];for(var a in u)o.o(u,a)&&(o.m[a]=u[a]);for(i&&i(o);n.length;)r[n.pop()]=1;e(t)}}(),t=o.x,o.x=function(){return o.e(9191).then(t)};o.x()}();
|
BIN
supervisor/api/panel/frontend_es5/085dd45b.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/085dd45b.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/098df01f.js
Normal file
1
supervisor/api/panel/frontend_es5/098df01f.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/098df01f.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/098df01f.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/0bcce3af.js
Normal file
1
supervisor/api/panel/frontend_es5/0bcce3af.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/0bcce3af.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/0bcce3af.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/0c3f120a.js
Normal file
1
supervisor/api/panel/frontend_es5/0c3f120a.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/0c3f120a.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/0c3f120a.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/106be44c.js
Normal file
1
supervisor/api/panel/frontend_es5/106be44c.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/106be44c.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/106be44c.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/122c4a56.js
Normal file
1
supervisor/api/panel/frontend_es5/122c4a56.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/122c4a56.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/122c4a56.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/12511fe3.js
Normal file
1
supervisor/api/panel/frontend_es5/12511fe3.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/12511fe3.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/12511fe3.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/15d3ce0f.js
Normal file
1
supervisor/api/panel/frontend_es5/15d3ce0f.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/15d3ce0f.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/15d3ce0f.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/1696b026.js
Normal file
1
supervisor/api/panel/frontend_es5/1696b026.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1696b026.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1696b026.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/1b81eab3.js
Normal file
1
supervisor/api/panel/frontend_es5/1b81eab3.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1b81eab3.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1b81eab3.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/1f1715bf.js
Normal file
1
supervisor/api/panel/frontend_es5/1f1715bf.js
Normal file
File diff suppressed because one or more lines are too long
BIN
supervisor/api/panel/frontend_es5/1f1715bf.js.gz
Normal file
BIN
supervisor/api/panel/frontend_es5/1f1715bf.js.gz
Normal file
Binary file not shown.
1
supervisor/api/panel/frontend_es5/1fde03cf.js
Normal file
1
supervisor/api/panel/frontend_es5/1fde03cf.js
Normal file
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