Skip to content

Commit 4dbece8

Browse files
committed
Merge remote-tracking branch 'origin/dev'
2 parents f107a73 + f731c63 commit 4dbece8

File tree

21 files changed

+1218
-129
lines changed

21 files changed

+1218
-129
lines changed

API.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ The addons from `addons` are only installed one.
5151
],
5252
"addons_repositories": [
5353
"REPO_URL"
54+
],
55+
"snapshots": [
56+
{
57+
"slug": "SLUG",
58+
"data": "ISO",
59+
"name": "Custom name"
60+
}
5461
]
5562
}
5663
```
@@ -148,7 +155,65 @@ Return QR-Code
148155
}
149156
```
150157

158+
### Backup/Snapshot
159+
160+
- POST `/snapshots/new/full`
161+
```json
162+
{
163+
"name": "Optional"
164+
}
165+
```
166+
167+
- POST `/snapshots/new/partial`
168+
```json
169+
{
170+
"name": "Optional",
171+
"addons": ["ADDON_SLUG"],
172+
"folders": ["FOLDER_NAME"]
173+
}
174+
```
175+
176+
- POST `/snapshots/reload`
177+
178+
- GET `/snapshots/{slug}/info`
179+
```json
180+
{
181+
"slug": "SNAPSHOT ID",
182+
"type": "full|partial",
183+
"name": "custom snapshot name / description",
184+
"date": "ISO",
185+
"size": "SIZE_IN_MB",
186+
"homeassistant": {
187+
"version": "INSTALLED_HASS_VERSION",
188+
"devices": []
189+
},
190+
"addons": [
191+
{
192+
"slug": "ADDON_SLUG",
193+
"name": "NAME",
194+
"version": "INSTALLED_VERSION"
195+
}
196+
],
197+
"repositories": ["URL"],
198+
"folders": ["NAME"]
199+
}
200+
```
201+
202+
- POST `/snapshots/{slug}/remove`
203+
204+
- POST `/snapshots/{slug}/restore/full`
205+
206+
- POST `/snapshots/{slug}/restore/partial`
207+
```json
208+
{
209+
"homeassistant": "bool",
210+
"addons": ["ADDON_SLUG"],
211+
"folders": ["FOLDER_NAME"]
212+
}
213+
```
214+
151215
### Host
216+
- POST `/host/reload`
152217

153218
- POST `/host/shutdown`
154219

@@ -231,6 +296,8 @@ Output the raw docker log
231296

232297
### REST API addons
233298

299+
- POST `/addons/reload`
300+
234301
- GET `/addons/{addon}/info`
235302
```json
236303
{

hassio/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
_LOGGER.info("Run Hassio setup")
3131
loop.run_until_complete(hassio.setup())
3232

33-
_LOGGER.info("Start Hassio task")
33+
_LOGGER.info("Start Hassio")
3434
loop.call_soon_threadsafe(loop.create_task, hassio.start())
3535
loop.call_soon_threadsafe(bootstrap.reg_signal, loop, hassio)
3636

hassio/addons/addon.py

Lines changed: 139 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
"""Init file for HassIO addons."""
22
from copy import deepcopy
33
import logging
4+
import json
45
from pathlib import Path, PurePath
56
import re
67
import shutil
8+
import tarfile
9+
from tempfile import TemporaryDirectory
710

811
import voluptuous as vol
912
from voluptuous.humanize import humanize_error
1013

11-
from .validate import validate_options, MAP_VOLUME
14+
from .validate import (
15+
validate_options, SCHEMA_ADDON_USER, SCHEMA_ADDON_SYSTEM,
16+
SCHEMA_ADDON_SNAPSHOT, MAP_VOLUME)
1217
from ..const import (
1318
ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_BOOT, ATTR_MAP,
1419
ATTR_OPTIONS, ATTR_PORTS, ATTR_SCHEMA, ATTR_IMAGE, ATTR_REPOSITORY,
1520
ATTR_URL, ATTR_ARCH, ATTR_LOCATON, ATTR_DEVICES, ATTR_ENVIRONMENT,
1621
ATTR_HOST_NETWORK, ATTR_TMPFS, ATTR_PRIVILEGED, ATTR_STARTUP,
17-
STATE_STARTED, STATE_STOPPED, STATE_NONE)
22+
STATE_STARTED, STATE_STOPPED, STATE_NONE, ATTR_USER, ATTR_SYSTEM,
23+
ATTR_STATE)
24+
from .util import check_installed
1825
from ..dock.addon import DockerAddon
19-
from ..tools import write_json_file
26+
from ..tools import write_json_file, read_json_file
2027

2128
_LOGGER = logging.getLogger(__name__)
2229

@@ -26,22 +33,33 @@
2633
class Addon(object):
2734
"""Hold data for addon inside HassIO."""
2835

29-
def __init__(self, config, loop, dock, data, addon_slug):
36+
def __init__(self, config, loop, dock, data, slug):
3037
"""Initialize data holder."""
38+
self.loop = loop
3139
self.config = config
3240
self.data = data
33-
self._id = addon_slug
34-
35-
if self._mesh is None:
36-
raise RuntimeError("{} not a valid addon!".format(self._id))
41+
self._id = slug
3742

3843
self.addon_docker = DockerAddon(config, loop, dock, self)
3944

4045
async def load(self):
4146
"""Async initialize of object."""
4247
if self.is_installed:
48+
self._validate_system_user()
4349
await self.addon_docker.attach()
4450

51+
def _validate_system_user(self):
52+
"""Validate internal data they read from file."""
53+
for data, schema in ((self.data.system, SCHEMA_ADDON_SYSTEM),
54+
(self.data.user, SCHEMA_ADDON_USER)):
55+
try:
56+
data[self._id] = schema(data[self._id])
57+
except vol.Invalid as err:
58+
_LOGGER.warning("Can't validate addon load %s -> %s", self._id,
59+
humanize_error(data[self._id], err))
60+
except KeyError:
61+
pass
62+
4563
@property
4664
def slug(self):
4765
"""Return slug/id of addon."""
@@ -88,6 +106,12 @@ def _set_update(self, version):
88106
self.data.user[self._id][ATTR_VERSION] = version
89107
self.data.save()
90108

109+
def _restore_data(self, user, system):
110+
"""Restore data to addon."""
111+
self.data.user[self._id] = deepcopy(user)
112+
self.data.system[self._id] = deepcopy(system)
113+
self.data.save()
114+
91115
@property
92116
def options(self):
93117
"""Return options with local changes."""
@@ -281,12 +305,9 @@ async def install(self, version=None):
281305
self._set_install(version)
282306
return True
283307

308+
@check_installed
284309
async def uninstall(self):
285310
"""Remove a addon."""
286-
if not self.is_installed:
287-
_LOGGER.error("Addon %s is not installed", self._id)
288-
return False
289-
290311
if not await self.addon_docker.remove():
291312
return False
292313

@@ -307,29 +328,21 @@ async def state(self):
307328
return STATE_STARTED
308329
return STATE_STOPPED
309330

331+
@check_installed
310332
async def start(self):
311333
"""Set options and start addon."""
312-
if not self.is_installed:
313-
_LOGGER.error("Addon %s is not installed", self._id)
314-
return False
315-
316334
return await self.addon_docker.run()
317335

336+
@check_installed
318337
async def stop(self):
319338
"""Stop addon."""
320-
if not self.is_installed:
321-
_LOGGER.error("Addon %s is not installed", self._id)
322-
return False
323-
324339
return await self.addon_docker.stop()
325340

341+
@check_installed
326342
async def update(self, version=None):
327343
"""Update addon."""
328-
if not self.is_installed:
329-
_LOGGER.error("Addon %s is not installed", self._id)
330-
return False
331-
332344
version = version or self.last_version
345+
333346
if version == self.version_installed:
334347
_LOGGER.warning(
335348
"Addon %s is already installed in %s", self._id, version)
@@ -341,18 +354,112 @@ async def update(self, version=None):
341354
self._set_update(version)
342355
return True
343356

357+
@check_installed
344358
async def restart(self):
345359
"""Restart addon."""
346-
if not self.is_installed:
347-
_LOGGER.error("Addon %s is not installed", self._id)
348-
return False
349-
350360
return await self.addon_docker.restart()
351361

362+
@check_installed
352363
async def logs(self):
353364
"""Return addons log output."""
354-
if not self.is_installed:
355-
_LOGGER.error("Addon %s is not installed", self._id)
356-
return False
357-
358365
return await self.addon_docker.logs()
366+
367+
@check_installed
368+
async def snapshot(self, tar_file):
369+
"""Snapshot a state of a addon."""
370+
with TemporaryDirectory(dir=str(self.config.path_tmp)) as temp:
371+
# store local image
372+
if self.need_build and not await \
373+
self.addon_docker.export_image(Path(temp, "image.tar")):
374+
return False
375+
376+
data = {
377+
ATTR_USER: self.data.user.get(self._id, {}),
378+
ATTR_SYSTEM: self.data.system.get(self._id, {}),
379+
ATTR_VERSION: self.version_installed,
380+
ATTR_STATE: await self.state(),
381+
}
382+
383+
# store local configs/state
384+
if not write_json_file(Path(temp, "addon.json"), data):
385+
_LOGGER.error("Can't write addon.json for %s", self._id)
386+
return False
387+
388+
# write into tarfile
389+
def _create_tar():
390+
"""Write tar inside loop."""
391+
with tarfile.open(tar_file, "w:gz",
392+
compresslevel=1) as snapshot:
393+
snapshot.add(temp, arcname=".")
394+
snapshot.add(self.path_data, arcname="data")
395+
396+
try:
397+
await self.loop.run_in_executor(None, _create_tar)
398+
except tarfile.TarError as err:
399+
_LOGGER.error("Can't write tarfile %s -> %s", tar_file, err)
400+
return False
401+
402+
return True
403+
404+
async def restore(self, tar_file):
405+
"""Restore a state of a addon."""
406+
with TemporaryDirectory(dir=str(self.config.path_tmp)) as temp:
407+
# extract snapshot
408+
def _extract_tar():
409+
"""Extract tar snapshot."""
410+
with tarfile.open(tar_file, "r:gz") as snapshot:
411+
snapshot.extractall(path=Path(temp))
412+
413+
try:
414+
await self.loop.run_in_executor(None, _extract_tar)
415+
except tarfile.TarError as err:
416+
_LOGGER.error("Can't read tarfile %s -> %s", tar_file, err)
417+
return False
418+
419+
# read snapshot data
420+
try:
421+
data = read_json_file(Path(temp, "addon.json"))
422+
except (OSError, json.JSONDecodeError) as err:
423+
_LOGGER.error("Can't read addon.json -> %s", err)
424+
425+
# validate
426+
try:
427+
data = SCHEMA_ADDON_SNAPSHOT(data)
428+
except vol.Invalid as err:
429+
_LOGGER.error("Can't validate %s, snapshot data -> %s",
430+
self._id, humanize_error(data, err))
431+
return False
432+
433+
# restore data / reload addon
434+
self._restore_data(data[ATTR_USER], data[ATTR_SYSTEM])
435+
436+
# check version / restore image
437+
version = data[ATTR_VERSION]
438+
if version != self.addon_docker.version:
439+
image_file = Path(temp, "image.tar")
440+
if image_file.is_file():
441+
await self.addon_docker.import_image(image_file, version)
442+
else:
443+
if await self.addon_docker.install(version):
444+
await self.addon_docker.cleanup()
445+
else:
446+
await self.addon_docker.stop()
447+
448+
# restore data
449+
def _restore_data():
450+
"""Restore data."""
451+
if self.path_data.is_dir():
452+
shutil.rmtree(str(self.path_data), ignore_errors=True)
453+
shutil.copytree(str(Path(temp, "data")), str(self.path_data))
454+
455+
try:
456+
await self.loop.run_in_executor(None, _restore_data)
457+
except shutil.Error as err:
458+
_LOGGER.error("Can't restore origin data -> %s", err)
459+
return False
460+
461+
# run addon
462+
if data[ATTR_STATE] == STATE_STARTED:
463+
return await self.start()
464+
465+
return True

0 commit comments

Comments
 (0)