diff --git a/libioc/Config/Jail/File/Fstab.py b/libioc/Config/Jail/File/Fstab.py index cfddcf47..d48fb1f1 100644 --- a/libioc/Config/Jail/File/Fstab.py +++ b/libioc/Config/Jail/File/Fstab.py @@ -147,10 +147,7 @@ def __hash__(self) -> int: return hash(None) -class Fstab( - libioc.Config.Jail.File.ResourceConfig, - collections.MutableSequence -): +class Fstab(collections.MutableSequence): """ Fstab configuration file wrapper. @@ -203,10 +200,7 @@ def path(self) -> str: path = self.file else: path = f"{self.jail.dataset.mountpoint}/{self.file}" - self._require_path_relative_to_resource( - filepath=path, - resource=self.jail - ) + self.jail._require_relative_path(path) return path @@ -400,7 +394,16 @@ def add_line( Use save() to write changes to the fstab file. """ - if self.__contains__(line): + if type(line) == FstabLine: + if self.jail._is_path_relative(line["destination"]) is False: + line = FstabLine(line) # clone to prevent mutation + line["destination"] = libioc.Types.AbsolutePath("/".join([ + self.jail.root_path, + line["destination"].lstrip("/") + ])) + + line_already_exists = self.__contains__(line) + if line_already_exists: destination = line["destination"] if replace is True: self.logger.verbose( @@ -425,11 +428,13 @@ def add_line( if type(line) == FstabLine: # destination is always relative to the jail resource - if line["destination"].startswith(self.jail.root_path) is False: - line["destination"] = libioc.Types.AbsolutePath("/".join([ + if self.jail._is_path_relative(line["destination"]) is False: + _destination = libioc.Types.AbsolutePath("/".join([ self.jail.root_path, line["destination"].strip("/") ])) + self.jail._require_relative_path(_destination) + line["destination"] = _destination libioc.helpers.require_no_symlink(str(line["destination"])) @@ -442,12 +447,17 @@ def add_line( os.makedirs(line["destination"], 0o700) if (auto_mount_jail and self.jail.running) is True: + destination = line["destination"] + self.jail._require_relative_path(destination) + self.logger.verbose( + f"auto-mount {destination}" + ) mount_command = [ "/sbin/mount", "-o", line["options"], "-t", line["type"], line["source"], - line["destination"] + destination ] libioc.helpers.exec(mount_command, logger=self.logger) _source = line["source"] @@ -632,7 +642,7 @@ def insert( # find FstabAutoPlaceholderLine instead line = list(filter( lambda x: isinstance(x, FstabAutoPlaceholderLine), - self._line + self._lines ))[0] real_index = self._lines.index(line) else: diff --git a/libioc/Config/Jail/File/__init__.py b/libioc/Config/Jail/File/__init__.py index 974e0f91..362cdc63 100644 --- a/libioc/Config/Jail/File/__init__.py +++ b/libioc/Config/Jail/File/__init__.py @@ -30,36 +30,6 @@ import libioc.helpers_object import libioc.LaunchableResource - -class ResourceConfig: - """Shared abstract code between various config files in a resource.""" - - def _require_path_relative_to_resource( - self, - filepath: str, - resource: 'libioc.LaunchableResource.LaunchableResource' - ) -> None: - - if self._is_path_relative_to_resource(filepath, resource) is False: - raise libioc.errors.SecurityViolationConfigJailEscape( - file=filepath - ) - - def _is_path_relative_to_resource( - self, - filepath: str, - resource: 'libioc.LaunchableResource.LaunchableResource' - ) -> bool: - - real_resource_path = self._resolve_path(resource.dataset.mountpoint) - real_file_path = self._resolve_path(filepath) - - return real_file_path.startswith(real_resource_path) - - def _resolve_path(self, filepath: str) -> str: - return os.path.realpath(os.path.abspath(filepath)) - - class ConfigFile(dict): """Abstraction of UCL file based config files in Resources.""" @@ -221,7 +191,7 @@ def __getitem__( return None -class ResourceConfigFile(ConfigFile, ResourceConfig): +class ResourceConfigFile(ConfigFile): """Abstraction of UCL file based config files in Resources.""" def __init__( @@ -238,8 +208,5 @@ def __init__( def path(self) -> str: """Absolute path to the file.""" path = f"{self.resource.root_dataset.mountpoint}/{self.file}" - self._require_path_relative_to_resource( - filepath=path, - resource=self.resource - ) + self.resource._require_relative_path(path) return os.path.abspath(path) diff --git a/libioc/Config/Type/JSON.py b/libioc/Config/Type/JSON.py index 9a3c776b..71b765fa 100644 --- a/libioc/Config/Type/JSON.py +++ b/libioc/Config/Type/JSON.py @@ -53,6 +53,6 @@ class DatasetConfigJSON( libioc.Config.Dataset.DatasetConfig, ConfigJSON ): - """ResourceConfig in JSON format.""" + """ConfigFile in JSON format.""" pass diff --git a/libioc/Config/Type/UCL.py b/libioc/Config/Type/UCL.py index f4124eb7..1cad9e6a 100644 --- a/libioc/Config/Type/UCL.py +++ b/libioc/Config/Type/UCL.py @@ -52,6 +52,6 @@ class DatasetConfigUCL( libioc.Config.Dataset.DatasetConfig, ConfigUCL ): - """ResourceConfig in UCL format.""" + """ConfigFile in UCL format.""" pass diff --git a/libioc/Jail.py b/libioc/Jail.py index b3063ba7..21793715 100644 --- a/libioc/Jail.py +++ b/libioc/Jail.py @@ -726,7 +726,7 @@ def _wrap_hook_script_command( self, commands: typing.Optional[typing.Union[str, typing.List[str]]], ignore_errors: bool=True, - jailed: bool=False, + jailed: bool=False, # ToDo: remove unused argument write_env: bool=True ) -> typing.List[str]: @@ -862,6 +862,7 @@ def _run_hook(self, hook_name: str) -> typing.Optional[ raise NotImplementedError("_run_hook only supports start/stop") def _ensure_script_dir(self) -> None: + """Ensure that the launch scripts dir exists.""" realpath = os.path.realpath(self.launch_script_dir) if realpath.startswith(self.dataset.mountpoint) is False: raise libioc.errors.SecurityViolationConfigJailEscape( @@ -2174,6 +2175,14 @@ def env(self) -> typing.Dict[str, str]: jail_env["IOC_JAIL_PATH"] = self.root_dataset.mountpoint jail_env["IOC_JID"] = str(self.jid) + jail_env["PATH"] = ":".join(( + "/sbin", + "/bin", + "/usr/sbin", + "/usr/bin", + "/usr/local/sbin", + "/usr/local/bin", + )) return jail_env diff --git a/libioc/Provisioning/__init__.py b/libioc/Provisioning/__init__.py index 80b67a02..346945e3 100644 --- a/libioc/Provisioning/__init__.py +++ b/libioc/Provisioning/__init__.py @@ -24,10 +24,90 @@ # POSSIBILITY OF SUCH DAMAGE. """iocage provisioning prototype.""" import typing +import urllib.parse import libioc.errors +import libioc.Types import libioc.helpers import libioc.Provisioning.ix +import libioc.Provisioning.puppet + +_SourceType = typing.Union[ + urllib.parse.ParseResult, + libioc.Types.AbsolutePath, +] +_SourceInputType = typing.Union[_SourceType, str] + +class Source(str): + + _value: _SourceType + + def __init__( + self, + value: _SourceInputType + ) -> None: + self.value = value + + @property + def value(self) -> _SourceType: + return self._value + + @value.setter + def value(self, value: _SourceInputType) -> None: + + if isinstance(value, libioc.Types.AbsolutePath) is True: + self._value = typing.Cast(libioc.Types.AbsolutePath, value) + return + elif isinstance(value, urllib.parse.ParseResult) is True: + url = typing.Cast(urllib.parse.ParseResult, value) + self.__require_valid_url(url) + self._value = _value + return + elif isinstance(value, str) is False: + raise TypeError( + f"Input must be URL, AbsolutePath or str, but was {type(value)}" + ) + + try: + self._value = libioc.Types.AbsolutePath(value) + return + except ValueError as e: + pass + + try: + url = urllib.parse.urlparse(value) + self.__require_valid_url(url) + self._value = url + return + except ValueError: + pass + + raise ValueError("Provisioning Source must be AbsolutePath or URL") + + @property + def local(self) -> bool: + """Return True when the source is local.""" + return (isinstance(self.value, libioc.Types.AbsolutePath) is True) + + @property + def remote(self) -> bool: + """Return True when the source is a remote URL.""" + return (self.local is False) + + def __require_valid_url(self, url: urllib.parse.ParseResult) -> None: + if url.scheme not in ("https", "http", "ssh", "git"): + raise ValueError(f"Invalid Source Scheme: {url.scheme}") + + def __str__(self) -> str: + """Return the Provisioning Source as string.""" + value = self.value + if isinstance(value, urllib.parse.ParseResult) is True: + return value.geturl() + else: + return str(value) + + def __repr__(self) -> str: + return f"" class Prototype: @@ -46,14 +126,14 @@ def method(self) -> str: self.__METHOD @property - def source(self) -> typing.Optional[str]: + def source(self) -> typing.Optional[Source]: config_value = self.jail.config["provisioning.source"] - return None if (config_value is None) else str(config_value) + return None if (config_value is None) else Source(config_value) @property def rev(self) -> typing.Optional[str]: config_value = self.jail.config["provisioning.rev"] - return None if (config_value is None) else str(config_value) + return None if (config_value is None) else str(Source(config_value)) def check_requirements(self) -> None: """Check requirements before executing the provisioner.""" @@ -84,7 +164,8 @@ def __available_provisioning_modules( self ) -> typing.Dict[str, Prototype]: return dict( - ix=libioc.Provisioning.ix + ix=libioc.Provisioning.ix, + puppet=libioc.Provisioning.puppet ) @property diff --git a/libioc/Provisioning/puppet.py b/libioc/Provisioning/puppet.py new file mode 100644 index 00000000..660c9dce --- /dev/null +++ b/libioc/Provisioning/puppet.py @@ -0,0 +1,266 @@ +# Copyright (c) 2017-2019, Stefan Grönke, Igor Galić +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted providing that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +"""ioc provisioner for use with `puppet apply`.""" +import typing +import os.path + +import libioc.errors +import libioc.events +import libioc.Pkg +import libioc.Provisioning + + +class ControlRepoUnavailableError(libioc.errors.IocException): + """Raised when the puppet control-repo is not available.""" + + def __init__( + self, + url: str, + reason: str, + logger: typing.Optional['libioc.Logger.Logger']=None + ) -> None: + msg = f"Puppet control-repo '{url}' is not available: {reason}" + libioc.errors.IocException.__init__( + self, + message=msg, + logger=logger + ) + + +class R10kDeployEvent(libioc.events.JailEvent): + """Deploy control repo and install puppet modules.""" + + pass + + +class PuppetApplyEvent(libioc.events.JailEvent): + """Apply the puppet manifest.""" + + pass + + +class ControlRepoDefinition(dict): + """Puppet control-repo definition.""" + + __source: str + _pkgs: typing.List[str] + + def __init__( + self, + source: 'libioc.Provisioning.Source', + logger: 'libioc.Logger.Logger' + ) -> None: + self.logger = logger + + self.source = source + self._pkgs = ['puppet6'] # make this a Global Varialbe + if source.remote is True: + self._pkgs += ['rubygem-r10k', 'git-lite'] + + @property + def source( + self + ) -> 'libioc.Provisioning.Source': + """Return the Puppet Control-Repo URL.""" + return self.__source + + @source.setter + def source(self, source: 'libioc.Provisioning.Source') -> None: + """Set the Puppet Control-Repo URL.""" + if isinstance(source, libioc.Provisioning.Source) is False: + raise TypeError("Source must be libioc.Provisioning.Source") + self.__source = source + + @property + def pkgs(self) -> typing.List[str]: + """Return list of packages required for this Provisioning method.""" + return self._pkgs + + +def provision( + self: 'libioc.Provisioning.Prototype', + event_scope: typing.Optional['libioc.events.Scope']=None +) -> typing.Generator['libioc.events.IocEvent', None, None]: + r""" + Provision the jail with Puppet apply using the supplied control-repo. + + The repo can either be a filesystem path, or a http[s]/git URL. + If the repo is a filesystem path, it will be mounted to + `/usr/local/etc/puppet/environments`. + If the repo is a URL, we will setup a ZFS dataset and mount that to + `/usr/local/etc/puppet/environments`, before deploying it with `r10k`. + + Example: + + ioc set \ + provisioning.method=puppet \ + provisioning.source=http://github.com/bsdci/puppet-control-repo \ + webserver + + This should install a webserver that listens on port 80, and delivers a + Hello-World HTML site. + """ + events = libioc.events + jailProvisioningEvent = events.JailProvisioning( + jail=self.jail, + scope=event_scope + ) + yield jailProvisioningEvent.begin() + _scope = jailProvisioningEvent.scope + jailProvisioningAssetDownloadEvent = events.JailProvisioningAssetDownload( + jail=self.jail, + scope=_scope + ) + + # download / mount provisioning assets + try: + yield jailProvisioningAssetDownloadEvent.begin() + if self.source is None: + raise libioc.errors.InvalidJailConfigValue( + property_name="provisioning.source", + reason="Source may not be empty", + logger=self.jail.logger + ) + pluginDefinition = ControlRepoDefinition( + source=self.source, + logger=self.jail.logger + ) + yield jailProvisioningAssetDownloadEvent.end() + except Exception as e: + yield jailProvisioningAssetDownloadEvent.fail(e) + raise e + + if self.source.remote: + mode = 'rw' # we'll need to run r10k here.. + plugin_dataset_name = f"{self.jail.dataset.name}/puppet" + plugin_dataset = self.jail.zfs.get_or_create_dataset( + plugin_dataset_name + ) + + mount_source = plugin_dataset.mountpoint + else: + mode = 'ro' + mount_source = self.source + + if self.jail.stopped is True: + started = True + jailStartEvent = libioc.events.JailStart( + jail=self.jail, + scope=jailProvisioningEvent.scope + ) + yield jailStartEvent.begin() + yield from self.jail.start(event_scope=jailStartEvent.scope) + yield jailStartEvent.end() + else: + started = False + + pkg = libioc.Pkg.Pkg( + logger=self.jail.logger, + zfs=self.jail.zfs, + host=self.jail.host + ) + + yield from pkg.install( + jail=self.jail, + packages=list(pluginDefinition.pkgs), + event_scope=jailProvisioningEvent.scope + ) + + puppet_env_dir = "/usr/local/etc/puppet/environments" + + self.jail.logger.spam("Mounting puppet environment") + self.jail.fstab.new_line( + source=mount_source, + destination=puppet_env_dir, + options=mode, + auto_create_destination=True, + replace=True + ) + + try: + if self.source.remote is True: + + r10kDeployEvent = R10kDeployEvent( + scope=jailProvisioningEvent.scope, + jail=self.jail + ) + + yield r10kDeployEvent.begin() + try: + r10k_dir = f"{self.jail.root_path}/usr/local/etc/r10k" + r10k_cfg = f"{r10k_dir}/r10k.yaml" + if not os.path.isdir(f"{r10k_dir}"): + os.mkdir(r10k_dir, mode=0o755) + self.jail.logger.verbose(f"Writing r10k config {r10k_cfg}") + with open(r10k_cfg, "w") as f: + f.write("\n".join([f"---", + ":sources:", + " puppet:", + f" basedir: {puppet_env_dir}", + f" remote: {self.source}\n"])) + + self.jail.logger.verbose("Deploying r10k config") + self.jail.exec([ + "r10k", + "deploy", + "environment", + "-pv" + ]) + except Exception as e: + yield r10kDeployEvent.fail(e) + raise e + yield r10kDeployEvent.end() + + puppetApplyEvent = PuppetApplyEvent( + scope=jailProvisioningEvent.scope, + jail=self.jail + ) + yield puppetApplyEvent.begin() + try: + self.jail.exec([ + "puppet", + "apply", + "--debug", + "--logdest", + "syslog", + f"{puppet_env_dir}/production/manifests/site.pp" + ]) + yield puppetApplyEvent.end() + except Exception as e: + yield puppetApplyEvent.fail(e) + raise e + + if started is True: + jailStopEvent = libioc.events.JailShutdown( + jail=self.jail, + scope=jailProvisioningEvent.scope + ) + yield jailStopEvent.begin() + yield from self.jail.stop(event_scope=jailStopEvent.scope) + yield jailStopEvent.end() + finally: + # in case anything fails the fstab mount needs to be removed + del self.jail.fstab[-1] + + yield jailProvisioningEvent.end() diff --git a/libioc/Resource.py b/libioc/Resource.py index 96c12e23..521868b2 100644 --- a/libioc/Resource.py +++ b/libioc/Resource.py @@ -365,6 +365,28 @@ def save(self) -> None: "This needs to be implemented by the inheriting class" ) + def _require_relative_path( + self, + filepath: str, + ) -> None: + if self._is_path_relative(filepath) is False: + raise libioc.errors.SecurityViolationConfigJailEscape( + file=filepath + ) + + def _is_path_relative( + self, + filepath: str + ) -> bool: + + real_resource_path = self._resolve_path(self.dataset.mountpoint) + real_file_path = self._resolve_path(filepath) + + return real_file_path.startswith(real_resource_path) + + def _resolve_path(self, filepath: str) -> str: + return os.path.realpath(os.path.abspath(filepath)) + class DefaultResource(Resource): """The resource storing the default configuration.""" diff --git a/libioc/Types.py b/libioc/Types.py index 28001c16..a0d6afa2 100644 --- a/libioc/Types.py +++ b/libioc/Types.py @@ -43,7 +43,7 @@ def __init__( raise TypeError("Path must be a string") if len(self.blacklist.findall(sequence)) > 0: - raise TypeError(f"Illegal path: {sequence}") + raise ValueError(f"Illegal path: {sequence}") self = sequence # type: ignore @@ -59,7 +59,7 @@ def __init__( raise TypeError("AbsolutePath must be a string or Path") if str(sequence).startswith("/") is False: - raise TypeError( + raise ValueError( f"Expected AbsolutePath to begin with /, but got: {sequence}" )