11"""Init file for HassIO addons."""
22from copy import deepcopy
33import logging
4+ import json
45from pathlib import Path , PurePath
56import re
67import shutil
8+ import tarfile
9+ from tempfile import TemporaryDirectory
710
811import voluptuous as vol
912from 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 )
1217from ..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
1825from ..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
2633class 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