示例#1
0
class PeriodicSnapshotTaskService(CRUDService):
    class Config:
        datastore = 'storage.task'
        datastore_prefix = 'task_'
        datastore_extend = 'pool.snapshottask.extend'
        datastore_extend_context = 'pool.snapshottask.extend_context'
        namespace = 'pool.snapshottask'
        cli_namespace = 'task.snapshot'

    @private
    async def extend_context(self, rows, extra):
        return {
            'state': await self.middleware.call('zettarepl.get_state'),
            'vmware': await self.middleware.call('vmware.query'),
        }

    @private
    async def extend(self, data, context):
        Cron.convert_db_format_to_schedule(data, begin_end=True)

        data['vmware_sync'] = any((vmware['filesystem'] == data['dataset'] or (
            data['recursive']
            and is_child(vmware['filesystem'], data['dataset'])))
                                  for vmware in context['vmware'])

        if 'error' in context['state']:
            data['state'] = context['state']['error']
        else:
            data['state'] = context['state']['tasks'].get(
                f'periodic_snapshot_task_{data["id"]}', {
                    'state': 'PENDING',
                })

        return data

    @accepts(
        Dict('periodic_snapshot_create',
             Dataset('dataset', required=True),
             Bool('recursive', required=True),
             List('exclude', items=[Dataset('item')]),
             Int('lifetime_value', required=True),
             Str('lifetime_unit',
                 enum=['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'],
                 required=True),
             Str('naming_schema',
                 required=True,
                 validators=[ReplicationSnapshotNamingSchema()]),
             Cron('schedule',
                  defaults={
                      'minute': '00',
                      'begin': '00:00',
                      'end': '23:59',
                  },
                  required=True,
                  begin_end=True),
             Bool('allow_empty', default=True),
             Bool('enabled', default=True),
             register=True))
    async def do_create(self, data):
        """
        Create a Periodic Snapshot Task

        Create a Periodic Snapshot Task that will take snapshots of specified `dataset` at specified `schedule`.
        Recursive snapshots can be created if `recursive` flag is enabled. You can `exclude` specific child datasets
        or zvols from the snapshot.
        Snapshots will be automatically destroyed after a certain amount of time, specified by
        `lifetime_value` and `lifetime_unit`.
        If multiple periodic tasks create snapshots at the same time (for example hourly and daily at 00:00) the snapshot
        will be kept until the last of these tasks reaches its expiry time.
        Snapshots will be named according to `naming_schema` which is a `strftime`-like template for snapshot name
        and must contain `%Y`, `%m`, `%d`, `%H` and `%M`.

        .. examples(websocket)::

          Create a recursive Periodic Snapshot Task for dataset `data/work` excluding `data/work/temp`. Snapshots
          will be created on weekdays every hour from 09:00 to 18:00 and will be stored for two weeks.

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "pool.snapshottask.create",
                "params": [{
                    "dataset": "data/work",
                    "recursive": true,
                    "exclude": ["data/work/temp"],
                    "lifetime_value": 2,
                    "lifetime_unit": "WEEK",
                    "naming_schema": "auto_%Y-%m-%d_%H-%M",
                    "schedule": {
                        "minute": "0",
                        "hour": "*",
                        "dom": "*",
                        "month": "*",
                        "dow": "1,2,3,4,5",
                        "begin": "09:00",
                        "end": "18:00"
                    }
                }]
            }
        """

        verrors = ValidationErrors()

        verrors.add_child('periodic_snapshot_create', await
                          self._validate(data))

        if verrors:
            raise verrors

        Cron.convert_schedule_to_db_format(data, begin_end=True)

        data['id'] = await self.middleware.call(
            'datastore.insert', self._config.datastore, data,
            {'prefix': self._config.datastore_prefix})

        await self.middleware.call('zettarepl.update_tasks')

        return await self.get_instance(data['id'])

    @accepts(
        Int('id', required=True),
        Patch('periodic_snapshot_create', 'periodic_snapshot_update',
              ('add', {
                  'name': 'fixate_removal_date',
                  'type': 'bool'
              }), ('attr', {
                  'update': True
              })),
    )
    async def do_update(self, id, data):
        """
        Update a Periodic Snapshot Task with specific `id`

        See the documentation for `create` method for information on payload contents

        .. examples(websocket)::

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "pool.snapshottask.update",
                "params": [
                    1,
                    {
                        "dataset": "data/work",
                        "recursive": true,
                        "exclude": ["data/work/temp"],
                        "lifetime_value": 2,
                        "lifetime_unit": "WEEK",
                        "naming_schema": "auto_%Y-%m-%d_%H-%M",
                        "schedule": {
                            "minute": "0",
                            "hour": "*",
                            "dom": "*",
                            "month": "*",
                            "dow": "1,2,3,4,5",
                            "begin": "09:00",
                            "end": "18:00"
                        }
                    }
                ]
            }
        """

        fixate_removal_date = data.pop('fixate_removal_date', False)

        old = await self.get_instance(id)
        new = old.copy()
        new.update(data)

        verrors = ValidationErrors()

        verrors.add_child('periodic_snapshot_update', await
                          self._validate(new))

        if not new['enabled']:
            for replication_task in await self.middleware.call(
                    'replication.query', [['enabled', '=', True]]):
                if any(periodic_snapshot_task['id'] == id
                       for periodic_snapshot_task in
                       replication_task['periodic_snapshot_tasks']):
                    verrors.add('periodic_snapshot_update.enabled', (
                        f'You can\'t disable this periodic snapshot task because it is bound to enabled replication '
                        f'task {replication_task["id"]!r}'))
                    break

        if verrors:
            raise verrors

        Cron.convert_schedule_to_db_format(new, begin_end=True)

        for key in ('vmware_sync', 'state'):
            new.pop(key, None)

        will_change_retention_for = None
        if fixate_removal_date:
            will_change_retention_for = await self.middleware.call(
                'pool.snapshottask.update_will_change_retention_for',
                id,
                data,
            )

        await self.middleware.call('datastore.update', self._config.datastore,
                                   id, new,
                                   {'prefix': self._config.datastore_prefix})

        if will_change_retention_for:
            await self.middleware.call('pool.snapshottask.fixate_removal_date',
                                       will_change_retention_for, old)

        await self.middleware.call('zettarepl.update_tasks')

        return await self.get_instance(id)

    @accepts(
        Int('id'),
        Dict(
            'options',
            Bool('fixate_removal_date', default=False),
        ),
    )
    async def do_delete(self, id, options):
        """
        Delete a Periodic Snapshot Task with specific `id`

        .. examples(websocket)::

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "pool.snapshottask.delete",
                "params": [
                    1
                ]
            }
        """

        for replication_task in await self.middleware.call(
                'replication.query', [
                    ['direction', '=', 'PUSH'],
                    ['also_include_naming_schema', '=', []],
                    ['enabled', '=', True],
                ]):
            if len(replication_task['periodic_snapshot_tasks']) == 1:
                if replication_task['periodic_snapshot_tasks'][0]['id'] == id:
                    raise CallError(
                        f'You are deleting the last periodic snapshot task bound to enabled replication task '
                        f'{replication_task["name"]!r} which will break it. Please, disable that replication task '
                        f'first.', )

        if options['fixate_removal_date']:
            will_change_retention_for = await self.middleware.call(
                'pool.snapshottask.delete_will_change_retention_for', id)

            if will_change_retention_for:
                task = await self.get_instance(id)
                await self.middleware.call(
                    'pool.snapshottask.fixate_removal_date',
                    will_change_retention_for, task)

        response = await self.middleware.call('datastore.delete',
                                              self._config.datastore, id)

        await self.middleware.call('zettarepl.update_tasks')

        return response

    @accepts(
        Dict(
            'periodic_snapshot_foreseen_count',
            Int('lifetime_value', required=True),
            Str('lifetime_unit',
                enum=['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'],
                required=True),
            Cron('schedule',
                 defaults={
                     'minute': '00',
                     'begin': '00:00',
                     'end': '23:59',
                 },
                 required=True,
                 begin_end=True),
            register=True,
        ))
    @returns(Int())
    def foreseen_count(self, data):
        """
        Returns a number of snapshots (per-dataset) being retained if a periodic snapshot task with specific parameters
        is created.
        """

        # Arbitrary year choice, fixed for unit tests repeatability. We don't need the precise answer, we only need
        # to evaluate the magnitude.
        base = datetime(2020, 1, 1, 0, 0, 0) - timedelta(seconds=1)

        multiplier = 1
        lifetime_value = data['lifetime_value']
        lifetime_unit = data['lifetime_unit']
        if lifetime_unit == 'YEAR' and lifetime_value > 1:
            # All years are the same, we don't need to run the same croniter multiple times for N years, just need to
            # run it for one year and multiply the result.
            multiplier = lifetime_value
            lifetime_value = 1

        until = base + timedelta(
            seconds=lifetime_value * {
                'HOUR': 3600,
                'DAY': 3600 * 24,
                'WEEK': 3600 * 24 * 7,
                'MONTH': 3600 * 24 * 30,
                'YEAR': 3600 * 24 * 365,
            }[lifetime_unit])

        iter = croniter_for_schedule(data['schedule'], base, datetime)
        count = 0
        while True:
            d = iter.get_next()
            if d > until:
                break

            if data['schedule']['begin'] <= d.time(
            ) <= data['schedule']['end']:
                count += 1

        return count * multiplier

    @accepts()
    @returns(Int())
    def max_count(self):
        """
        Returns a maximum amount of snapshots (per-dataset) the system can sustain.
        """

        # There is a limit to how many snapshots Windows will present to users through File Explorer. If we respond
        # with too many, then File Explorer will show no snapshots available.
        return 512

    @accepts()
    @returns(Int())
    def max_total_count(self):
        """
        Returns a maximum amount of snapshots (total) the system can sustain.
        """

        # Having too many snapshots results in various performance complications (mainly, when listing them).
        # This is a random round number that is large enough and does not cause issues in most use cases.
        return 10000

    @item_method
    @accepts(Int("id"))
    async def run(self, id):
        """
        Execute a Periodic Snapshot Task of `id`.
        """
        task = await self.get_instance(id)

        if not task["enabled"]:
            raise CallError("Task is not enabled")

        await self.middleware.call("zettarepl.run_periodic_snapshot_task",
                                   task["id"])

    async def _validate(self, data):
        verrors = ValidationErrors()

        if data['dataset'] not in (
                await self.middleware.call('pool.filesystem_choices')):
            verrors.add('dataset', 'ZFS dataset or zvol not found')

        if not data['recursive'] and data['exclude']:
            verrors.add(
                'exclude',
                'Excluding datasets or zvols is not necessary for non-recursive periodic snapshot tasks'
            )

        for i, v in enumerate(data['exclude']):
            if not v.startswith(f'{data["dataset"]}/'):
                verrors.add(
                    f'exclude.{i}',
                    'Excluded dataset or zvol should be a child or other descendant of selected dataset'
                )

        return verrors
示例#2
0
class ReportingService(ConfigService):
    class Config:
        datastore = 'system.reporting'
        cli_namespace = 'system.reporting'

    ENTRY = Dict(
        'reporting_entry',
        Bool('cpu_in_percentage', required=True),
        Str('graphite', required=True),
        Bool('graphite_separateinstances', required=True),
        Int('graph_age', validators=[Range(min=1, max=60)], required=True),
        Int('graph_points', validators=[Range(min=1, max=4096)],
            required=True),
        Int('id', required=True),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.__rrds = {}
        for name, klass in RRD_PLUGINS.items():
            self.__rrds[name] = klass(self.middleware)

    @accepts(
        Patch(
            'reporting_entry',
            'reporting_update',
            ('add', Bool('confirm_rrd_destroy')),
            ('rm', {
                'name': 'id'
            }),
            ('attr', {
                'update': True
            }),
        ), )
    async def do_update(self, data):
        """
        Configure Reporting Database settings.

        If `cpu_in_percentage` is `true`, collectd reports CPU usage in percentage instead of "jiffies".

        `graphite` specifies a destination hostname or IP for collectd data sent by the Graphite plugin..

        `graphite_separateinstances` corresponds to collectd SeparateInstances option.

        `graph_age` specifies the maximum age of stored graphs in months. `graph_points` is the number of points for
        each hourly, daily, weekly, etc. graph. Changing these requires destroying the current reporting database,
        so when these fields are changed, an additional `confirm_rrd_destroy: true` flag must be present.

        .. examples(websocket)::

          Update reporting settings

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "reporting.update",
                "params": [{
                    "cpu_in_percentage": false,
                    "graphite": "",
                }]
            }

          Recreate reporting database with new settings

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "reporting.update",
                "params": [{
                    "graph_age": 12,
                    "graph_points": 1200,
                    "confirm_rrd_destroy": true,
                }]
            }
        """

        confirm_rrd_destroy = data.pop('confirm_rrd_destroy', False)

        old = await self.config()

        new = copy.deepcopy(old)
        new.update(data)

        verrors = ValidationErrors()

        destroy_database = False
        for k in ['graph_age', 'graph_points']:
            if old[k] != new[k]:
                destroy_database = True

                if not confirm_rrd_destroy:
                    verrors.add(
                        f'reporting_update.{k}',
                        'Changing this option requires destroying the reporting database. This action '
                        'must be confirmed by setting confirm_rrd_destroy flag',
                    )

        if verrors:
            raise verrors

        await self.middleware.call('datastore.update', self._config.datastore,
                                   old['id'], new,
                                   {'prefix': self._config.datastore_prefix})

        if destroy_database:
            await self.clear(False)

        await self.middleware.call('service.restart', 'collectd')

        return await self.config()

    @accepts(Bool('start_collectd', default=True, hidden=True))
    @returns()
    async def clear(self, start_collectd):
        """
        Clear reporting database.
        """
        await self.middleware.call('service.stop', 'rrdcached')
        await run('sh',
                  '-c',
                  'rm --one-file-system -rf /var/db/collectd/rrd/*',
                  check=False)
        await self.middleware.call('reporting.setup')
        await self.middleware.call('service.start', 'rrdcached')

        if start_collectd:
            await self.middleware.call('service.start', 'collectd')

    @filterable
    @filterable_returns(
        Dict(
            'graph',
            Str('name'),
            Str('title'),
            Str('vertical_label'),
            List('identifiers', items=[Str('identifier')], null=True),
            Bool('stacked'),
            Bool('stacked_show_total'),
        ))
    def graphs(self, filters, options):
        return filter_list(
            [i.__getstate__() for i in self.__rrds.values() if i.has_data()],
            filters, options)

    def __rquery_to_start_end(self, query):
        unit = query.get('unit')
        if unit:
            verrors = ValidationErrors()
            for i in ('start', 'end'):
                if i in query:
                    verrors.add(
                        f'reporting_query.{i}',
                        f'{i!r} should only be used if "unit" attribute is not provided.',
                    )
            verrors.check()
        else:
            if 'start' not in query:
                unit = 'HOURLY'
            else:
                starttime = query['start']
                endtime = query.get('end') or 'now'

        if unit:
            unit = unit[0].lower()
            page = query['page']
            starttime = f'end-{page + 1}{unit}'
            if not page:
                endtime = 'now'
            else:
                endtime = f'now-{page}{unit}'
        return starttime, endtime

    @accepts(
        List('graphs',
             items=[
                 Dict(
                     'graph',
                     Str('name', required=True),
                     Str('identifier', default=None, null=True),
                 ),
             ],
             empty=False),
        Dict(
            'reporting_query',
            Str('unit', enum=['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR']),
            Int('page', default=0),
            Str('start', empty=False),
            Str('end', empty=False),
            Bool('aggregate', default=True),
            register=True,
        ))
    @returns(
        List('reporting_data',
             items=[
                 Dict('graph_reporting_data',
                      Str('name', required=True),
                      Str('identifier', required=True, null=True),
                      List('data'),
                      Dict(
                          'aggregations',
                          List('min'),
                          List('max'),
                          List('mean'),
                      ),
                      additional_attrs=True)
             ]))
    def get_data(self, graphs, query):
        """
        Get reporting data for given graphs.

        List of possible graphs can be retrieved using `reporting.graphs` call.

        For the time period of the graph either `unit` and `page` OR `start` and `end` should be
        used, not both.

        `aggregate` will return aggregate available data for each graph (e.g. min, max, mean).

        .. examples(websocket)::

          Get graph data of "nfsstat" from the last hour.

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "reporting.get_data",
                "params": [
                    [{"name": "nfsstat"}],
                    {"unit": "HOURLY"},
                ]
            }

        """
        starttime, endtime = self.__rquery_to_start_end(query)
        rv = []
        for i in graphs:
            try:
                rrd = self.__rrds[i['name']]
            except KeyError:
                raise CallError(f'Graph {i["name"]!r} not found.',
                                errno.ENOENT)
            rv.append(
                rrd.export(i['identifier'],
                           starttime,
                           endtime,
                           aggregate=query['aggregate']))
        return rv

    @private
    @accepts(Ref('reporting_query'))
    def get_all(self, query):
        starttime, endtime = self.__rquery_to_start_end(query)
        rv = []
        for rrd in self.__rrds.values():
            idents = rrd.get_identifiers()
            if idents is None:
                idents = [None]
            for ident in idents:
                rv.append(
                    rrd.export(ident,
                               starttime,
                               endtime,
                               aggregate=query['aggregate']))
        return rv
示例#3
0
class BootService(Service):
    class Config:
        cli_namespace = 'system.boot'

    @private
    async def pool_name(self):
        return BOOT_POOL_NAME

    @accepts()
    @returns(
        Dict(
            'boot_pool_state',
            Str('name'),
            Str('id'),
            Str('guid'),
            Str('hostname'),
            Str('status'),
            Bool('healthy'),
            Int('error_count'),
            Dict(
                'root_dataset',
                Str('id'),
                Str('name'),
                Str('pool'),
                Str('type'),
                Dict(
                    'properties',
                    additional_attrs=True,
                ),
                Str('mountpoint', null=True),
                Bool('encrypted'),
                Str('encryption_root', null=True),
                Bool('key_loaded'),
            ),
            Dict(
                'properties',
                additional_attrs=True,
            ),
            List('features',
                 items=[
                     Dict(
                         'feature_item',
                         Str('name'),
                         Str('guid'),
                         Str('description'),
                         Str('state'),
                     )
                 ]),
            Dict(
                'scan',
                Str('function'),
                Str('state'),
                Datetime('start_time', null=True),
                Datetime('end_time', null=True),
                Float('percentage'),
                Int('bytes_to_process'),
                Int('bytes_processed'),
                Datetime('pause', null=True),
                Int('errors'),
                Int('bytes_issued', null=True),
                Int('total_secs_left', null=True),
            ),
            Dict(
                'root_vdev',
                Str('type'),
                Str('path', null=True),
                Str('guid'),
                Str('status'),
                Dict(
                    'stats',
                    Int('timestamp'),
                    Int('read_errors'),
                    Int('write_errors'),
                    Int('checksum_errors'),
                    List('ops', items=[Int('op')]),
                    List('bytes', items=[Int('byte')]),
                    Int('size'),
                    Int('allocated'),
                    Int('fragmentation'),
                    Int('self_healed'),
                    Int('configured_ashift'),
                    Int('logical_ashift'),
                    Int('physical_ashift'),
                ),
            ),
            Dict(
                'groups',
                additional_attrs=True,
            ),
            Str('status_code'),
            Str('status_detail'),
        ), )
    async def get_state(self):
        """
        Returns the current state of the boot pool, including all vdevs, properties and datasets.
        """
        return await self.middleware.call('zfs.pool.query',
                                          [('name', '=', BOOT_POOL_NAME)],
                                          {'get': True})

    @accepts()
    @returns(List('disks', items=[Str('disk')]))
    async def get_disks(self):
        """
        Returns disks of the boot pool.
        """
        return await self.middleware.call('zfs.pool.get_disks', BOOT_POOL_NAME)

    @private
    async def get_boot_type(self):
        """
        Get the boot type of the boot pool.

        Returns:
            "BIOS", "EFI", None
        """
        if osc.IS_LINUX:
            # https://wiki.debian.org/UEFI
            return 'EFI' if os.path.exists('/sys/firmware/efi') else 'BIOS'
        else:
            return await self.__get_boot_type_freebsd()

    async def __get_boot_type_freebsd(self):
        await self.middleware.run_in_thread(geom.scan)
        labelclass = geom.class_by_name('PART')
        efi = bios = 0
        for disk in await self.get_disks():
            for e in labelclass.xml.findall(
                    f".//geom[name='{disk}']/provider/config/type"):
                if e.text == 'efi':
                    efi += 1
                elif e.text == 'freebsd-boot':
                    bios += 1
        if efi == 0 and bios == 0:
            return None
        if bios > 0:
            return 'BIOS'
        return 'EFI'

    @accepts(
        Str('dev'),
        Dict(
            'options',
            Bool('expand', default=False),
        ),
    )
    @returns()
    @job(lock='boot_attach')
    async def attach(self, job, dev, options):
        """
        Attach a disk to the boot pool, turning a stripe into a mirror.

        `expand` option will determine whether the new disk partition will be
                 the maximum available or the same size as the current disk.
        """

        disks = list(await self.get_disks())
        if len(disks) > 1:
            raise CallError('3-way mirror not supported')

        format_opts = {}
        if not options['expand']:
            # Lets try to find out the size of the current freebsd-zfs partition so
            # the new partition is not bigger, preventing size mismatch if one of
            # them fail later on. See #21336
            zfs_part = await self.middleware.call('disk.get_partition',
                                                  disks[0], 'ZFS')
            if zfs_part:
                format_opts['size'] = zfs_part['size']

        swap_part = await self.middleware.call('disk.get_partition', disks[0],
                                               'SWAP')
        if swap_part:
            format_opts['swap_size'] = swap_part['size']
        await self.middleware.call('boot.format', dev, format_opts)

        pool = await self.middleware.call('zfs.pool.query',
                                          [['name', '=', BOOT_POOL_NAME]],
                                          {'get': True})

        zfs_dev_part = await self.middleware.call('disk.get_partition', dev,
                                                  'ZFS')
        extend_pool_job = await self.middleware.call(
            'zfs.pool.extend', BOOT_POOL_NAME, None,
            [{
                'target': pool['groups']['data'][0]['guid'],
                'type': 'DISK',
                'path': f'/dev/{zfs_dev_part["name"]}'
            }])

        await self.middleware.call('boot.install_loader', dev)

        await job.wrap(extend_pool_job)

        # If the user is upgrading his disks, let's set expand to True to make sure that we
        # register the new disks capacity which increase the size of the pool
        await self.middleware.call('zfs.pool.online', BOOT_POOL_NAME,
                                   zfs_dev_part['name'], True)

        await self.update_initramfs()

    @accepts(Str('dev'))
    @returns()
    async def detach(self, dev):
        """
        Detach given `dev` from boot pool.
        """
        await self.middleware.call('zfs.pool.detach', BOOT_POOL_NAME, dev,
                                   {'clear_label': True})
        await self.update_initramfs()

    @accepts(Str('label'), Str('dev'))
    @returns()
    async def replace(self, label, dev):
        """
        Replace device `label` on boot pool with `dev`.
        """
        format_opts = {}
        disks = list(await self.get_disks())
        swap_part = await self.middleware.call('disk.get_partition', disks[0],
                                               'SWAP')
        if swap_part:
            format_opts['swap_size'] = swap_part['size']

        await self.middleware.call('boot.format', dev, format_opts)
        zfs_dev_part = await self.middleware.call('disk.get_partition', dev,
                                                  'ZFS')
        await self.middleware.call('zfs.pool.replace', BOOT_POOL_NAME, label,
                                   zfs_dev_part['name'])
        await self.middleware.call('boot.install_loader', dev)
        await self.update_initramfs()

    @accepts()
    @returns()
    @job(lock='boot_scrub')
    async def scrub(self, job):
        """
        Scrub on boot pool.
        """
        subjob = await self.middleware.call('zfs.pool.scrub', BOOT_POOL_NAME)
        return await job.wrap(subjob)

    @accepts(Int('interval', validators=[Range(min=1)]))
    @returns(Int('interval'))
    async def set_scrub_interval(self, interval):
        """
        Set Automatic Scrub Interval value in days.
        """
        await self.middleware.call(
            'datastore.update',
            'system.advanced',
            (await self.middleware.call('system.advanced.config'))['id'],
            {'adv_boot_scrub': interval},
        )
        return interval

    @accepts()
    @returns(Int('interval'))
    async def get_scrub_interval(self):
        """
        Get Automatic Scrub Interval value in days.
        """
        return (await
                self.middleware.call('system.advanced.config'))['boot_scrub']

    @private
    async def update_initramfs(self):
        """
        Returns true if initramfs was updated and false otherwise.
        """
        cp = await run('/usr/local/bin/truenas-initrd.py',
                       '/',
                       encoding='utf8',
                       errors='ignore',
                       check=False)
        if cp.returncode > 1:
            raise CallError(f'Failed to update initramfs: {cp.stderr}')

        return cp.returncode == 1

    @private
    async def expand(self):
        boot_pool = await self.middleware.call('boot.pool_name')
        for device in await self.middleware.call('zfs.pool.get_devices',
                                                 boot_pool):
            try:
                await self.expand_device(device)
            except CallError as e:
                self.middleware.logger.error(
                    'Error trying to expand boot pool partition %r: %r',
                    device, e)
            except Exception:
                self.middleware.logger.error(
                    'Error trying to expand boot pool partition %r',
                    device,
                    exc_info=True)

    @private
    async def expand_device(self, device):
        disk = await self.middleware.call('disk.get_disk_from_partition',
                                          device)

        partitions = await self.middleware.call('disk.list_partitions', disk)
        if len(partitions) != 3:
            raise CallError(f'Expected 3 partitions, found {len(partitions)}')

        if partitions[-1]['name'] != device:
            raise CallError(f'{device} is not the last partition')

        if partitions[-1]['partition_number'] != 3:
            raise CallError(f'{device} is not 3rd partition')

        if partitions[-1]['start_sector'] != partitions[-2]['end_sector'] + 1:
            raise CallError(
                f'{device} does not immediately follow the 2nd partition')

        disk_size = await self.middleware.call('disk.get_dev_size', disk)
        if partitions[-1]['end'] > disk_size / 1.1:
            return

        self.middleware.logger.info(
            'Resizing boot pool partition %r from %r (disk_size = %r)', device,
            partitions[-1]['end'], disk_size)
        await run('sgdisk',
                  '-d',
                  '3',
                  f'/dev/{disk}',
                  encoding='utf-8',
                  errors='ignore')
        await run('sgdisk',
                  '-N',
                  '3',
                  f'/dev/{disk}',
                  encoding='utf-8',
                  errors='ignore')
        await run('partprobe', encoding='utf-8', errors='ignore')
        await run('zpool',
                  'online',
                  '-e',
                  'boot-pool',
                  device,
                  encoding='utf-8',
                  errors='ignore')
示例#4
0
class SharingSMBService(CRUDService):
    class Config:
        namespace = 'sharing.smb'
        datastore = 'sharing.cifs_share'
        datastore_prefix = 'cifs_'
        datastore_extend = 'sharing.smb.extend'

    @accepts(
        Dict('sharingsmb_create',
             Str('purpose',
                 enum=[x.name for x in SMBSharePreset],
                 default=SMBSharePreset.DEFAULT_SHARE.name),
             Str('path', required=True),
             Str('path_suffix', default=''),
             Bool('home', default=False),
             Str('name', max_length=80),
             Str('comment', default=''),
             Bool('ro', default=False),
             Bool('browsable', default=True),
             Bool('timemachine', default=False),
             Bool('recyclebin', default=False),
             Bool('guestok', default=False),
             Bool('abe', default=False),
             List('hostsallow', default=[]),
             List('hostsdeny', default=[]),
             Bool('aapl_name_mangling', default=False),
             Bool('acl', default=True),
             Bool('durablehandle', default=True),
             Bool('shadowcopy', default=True),
             Bool('streams', default=True),
             Bool('fsrvp', default=False),
             Str('auxsmbconf', max_length=None, default=''),
             Bool('enabled', default=True),
             register=True))
    async def do_create(self, data):
        """
        Create a SMB Share.

        `purpose` applies common configuration presets depending on intended purpose.

        `timemachine` when set, enables Time Machine backups for this share.

        `ro` when enabled, prohibits write access to the share.

        `guestok` when enabled, allows access to this share without a password.

        `hostsallow` is a list of hostnames / IP addresses which have access to this share.

        `hostsdeny` is a list of hostnames / IP addresses which are not allowed access to this share. If a handful
        of hostnames are to be only allowed access, `hostsdeny` can be passed "ALL" which means that it will deny
        access to ALL hostnames except for the ones which have been listed in `hostsallow`.

        `acl` enables support for storing the SMB Security Descriptor as a Filesystem ACL.

        `streams` enables support for storing alternate datastreams as filesystem extended attributes.

        `fsrvp` enables support for the filesystem remote VSS protocol. This allows clients to create
        ZFS snapshots through RPC.

        `shadowcopy` enables support for the volume shadow copy service.

        `auxsmbconf` is a string of additional smb4.conf parameters not covered by the system's API.
        """
        verrors = ValidationErrors()
        path = data['path']

        await self.clean(data, 'sharingsmb_create', verrors)
        await self.validate(data, 'sharingsmb_create', verrors)

        if verrors:
            raise verrors

        if path and not os.path.exists(path):
            try:
                os.makedirs(path)
            except OSError as e:
                raise CallError(f'Failed to create {path}: {e}')

        await self.apply_presets(data)
        await self.compress(data)
        vuid = await self.generate_vuid(data['timemachine'])
        data.update({'vuid': vuid})
        data['id'] = await self.middleware.call(
            'datastore.insert', self._config.datastore, data,
            {'prefix': self._config.datastore_prefix})

        await self.middleware.call('sharing.smb.reg_addshare', data)
        await self.extend(data)  # We should do this in the insert call ?

        enable_aapl = await self.check_aapl(data)

        if enable_aapl:
            await self._service_change('cifs', 'restart')
        else:
            await self._service_change('cifs', 'reload')

        return data

    @accepts(Int('id'),
             Patch('sharingsmb_create', 'sharingsmb_update', ('attr', {
                 'update': True
             })))
    async def do_update(self, id, data):
        """
        Update SMB Share of `id`.
        """
        verrors = ValidationErrors()
        path = data.get('path')

        old = await self.middleware.call(
            'datastore.query', self._config.datastore, [('id', '=', id)], {
                'extend': self._config.datastore_extend,
                'prefix': self._config.datastore_prefix,
                'get': True
            })

        new = old.copy()
        new.update(data)

        oldname = 'homes' if old['home'] else old['name']
        newname = 'homes' if new['home'] else new['name']

        new['vuid'] = await self.generate_vuid(new['timemachine'], new['vuid'])
        await self.clean(new, 'sharingsmb_update', verrors, id=id)
        await self.validate(new, 'sharingsmb_update', verrors, old=old)

        if verrors:
            raise verrors

        if path and not os.path.exists(path):
            try:
                os.makedirs(path)
            except OSError as e:
                raise CallError(f'Failed to create {path}: {e}')

        if old['purpose'] != new['purpose']:
            await self.apply_presets(new)

        await self.compress(new)
        await self.middleware.call('datastore.update', self._config.datastore,
                                   id, new,
                                   {'prefix': self._config.datastore_prefix})

        enable_aapl = await self.check_aapl(new)
        if newname != oldname:
            # This is disruptive change. Share is actually being removed and replaced.
            # Forcibly closes any existing SMB sessions.
            await self.close_share(oldname)
            try:
                await self.middleware.call('sharing.smb.reg_delshare', oldname)
            except Exception:
                self.logger.warning('Failed to remove stale share [%s]',
                                    old['name'],
                                    exc_info=True)
            await self.middleware.call('sharing.smb.reg_addshare', new)
        else:
            diff = await self.middleware.call(
                'sharing.smb.diff_middleware_and_registry', new['name'], new)
            share_name = new['name'] if not new['home'] else 'homes'
            await self.middleware.call('sharing.smb.apply_conf_diff',
                                       'REGISTRY', share_name, diff)

        await self.extend(new)  # same here ?

        if enable_aapl:
            await self._service_change('cifs', 'restart')
        else:
            await self._service_change('cifs', 'reload')

        return new

    @accepts(Int('id'))
    async def do_delete(self, id):
        """
        Delete SMB Share of `id`. This will forcibly disconnect SMB clients
        that are accessing the share.
        """
        share = await self._get_instance(id)
        result = await self.middleware.call('datastore.delete',
                                            self._config.datastore, id)
        await self.close_share(share['name'])
        try:
            await self.middleware.call(
                'smb.sharesec._delete',
                share['name'] if not share['home'] else 'homes')
        except Exception:
            self.logger.debug('Failed to delete share ACL for [%s].',
                              share['name'],
                              exc_info=True)

        try:
            await self.middleware.call(
                'sharing.smb.reg_delshare',
                share['name'] if not share['home'] else 'homes')
        except Exception:
            self.logger.warn('Failed to remove registry entry for [%s].',
                             share['name'],
                             exc_info=True)

        if share['timemachine']:
            await self.middleware.call('service.restart', 'mdns')

        return result

    @private
    async def check_aapl(self, data):
        """
        Returns whether we changed the global aapl support settings.
        """
        aapl_extensions = (
            await self.middleware.call('smb.config'))['aapl_extensions']

        if not aapl_extensions and data['timemachine']:
            await self.middleware.call('datastore.update', 'services_cifs', 1,
                                       {'cifs_srv_aapl_extensions': True})
            return True

        return False

    @private
    async def close_share(self, share_name):
        c = await run(
            [SMBCmd.SMBCONTROL.value, 'smbd', 'close-share', share_name],
            check=False)
        if c.returncode != 0:
            self.logger.warn('Failed to close smb share [%s]: [%s]',
                             share_name,
                             c.stderr.decode().strip())

    @private
    async def clean(self, data, schema_name, verrors, id=None):
        data['name'] = await self.name_exists(data, schema_name, verrors, id)

    @private
    async def validate(self, data, schema_name, verrors, old=None):
        home_result = await self.home_exists(data['home'], schema_name,
                                             verrors, old)

        if home_result:
            verrors.add(f'{schema_name}.home',
                        'Only one share is allowed to be a home share.')
        elif not home_result and not data['path']:
            verrors.add(f'{schema_name}.path', 'This field is required.')

        if data['path']:
            await check_path_resides_within_volume(verrors, self.middleware,
                                                   f"{schema_name}.path",
                                                   data['path'])

        if not data['acl'] and not await self.middleware.call(
                'filesystem.acl_is_trivial', data['path']):
            verrors.add(
                f'{schema_name}.acl',
                f'ACL detected on {data["path"]}. ACLs must be stripped prior to creation '
                'of SMB share.')

        if data.get('name') and data['name'].lower() in [
                'global', 'homes', 'printers'
        ]:
            verrors.add(
                f'{schema_name}.name',
                f'{data["name"]} is a reserved section name, please select another one'
            )
        if data.get('path_suffix') and len(data['path_suffix'].split('/')) > 2:
            verrors.add(
                f'{schema_name}.name',
                'Path suffix may not contain more than two components.')

    @private
    async def home_exists(self, home, schema_name, verrors, old=None):
        home_filters = [('home', '=', True)]
        home_result = None

        if home:
            if old and old['id'] is not None:
                id = old['id']

                if not old['home']:
                    home_filters.append(('id', '!=', id))
                    # The user already had this set as the home share
                    home_result = await self.middleware.call(
                        'datastore.query', self._config.datastore,
                        home_filters,
                        {'prefix': self._config.datastore_prefix})

        return home_result

    @private
    async def auxsmbconf_dict(self, aux, direction="TO"):
        ret = None
        if direction == 'TO':
            ret = {}
            for entry in aux.splitlines():
                try:
                    kv = param.split('=', 1)
                    ret[kv[0].strip()] = kv[1].strip()
                except Exception:
                    self.logger.debug(
                        "Share contains invalid auxiliary parameter: [%s]",
                        param)
            return ret

        if direction == 'FROM':
            return '\n'.join([f'{k}={v}' for k, v in aux.items()])

    @private
    async def name_exists(self, data, schema_name, verrors, id=None):
        name = data['name']
        path = data['path']

        if path and not name:
            name = path.rsplit('/', 1)[-1]

        name_filters = [('name', '=', name)]

        if id is not None:
            name_filters.append(('id', '!=', id))

        name_result = await self.middleware.call(
            'datastore.query', self._config.datastore, name_filters,
            {'prefix': self._config.datastore_prefix})

        if name_result:
            verrors.add(f'{schema_name}.name',
                        'A share with this name already exists.')

        return name

    @private
    async def extend(self, data):
        data['hostsallow'] = data['hostsallow'].split()
        data['hostsdeny'] = data['hostsdeny'].split()
        if data['fsrvp']:
            data['shadowcopy'] = True

        if 'share_acl' in data:
            data.pop('share_acl')

        return data

    @private
    async def compress(self, data):
        data['hostsallow'] = ' '.join(data['hostsallow'])
        data['hostsdeny'] = ' '.join(data['hostsdeny'])

        return data

    @private
    async def generate_vuid(self, timemachine, vuid=""):
        try:
            if timemachine and vuid:
                uuid.UUID(vuid, version=4)
        except ValueError:
            self.logger.debug(
                f"Time machine VUID string ({vuid}) is invalid. Regenerating.")
            vuid = ""

        if timemachine and not vuid:
            vuid = str(uuid.uuid4())

        return vuid

    @private
    async def apply_presets(self, data):
        """
        Apply settings from presets. Only include auxiliary parameters
        from preset if user-defined aux parameters already exist. In this
        case user-defined takes precedence.
        """
        params = (SMBSharePreset[data["purpose"]].value)["params"].copy()
        aux = params.pop("auxsmbconf")
        data.update(params)
        if data["auxsmbconf"]:
            preset_aux = await self.auxsmbconf_dict(aux, direction="TO")
            data_aux = await self.auxsmbconf_dict(data["auxsmbconf"],
                                                  direction="TO")
            preset_aux.update(data_aux)
            data["auxsmbconf"] = await self.auxsmbconf_dict(preset_aux,
                                                            direction="FROM")

        return data

    @accepts()
    async def presets(self):
        """
        Retrieve pre-defined configuration sets for specific use-cases. These parameter
        combinations are often non-obvious, but beneficial in these scenarios.
        """
        return {x.name: x.value for x in SMBSharePreset}
示例#5
0
文件: update.py 项目: tejp/freenas
class UpdateService(Service):

    @accepts()
    def get_trains(self):
        """
        Returns available trains dict and the currently configured train as well as the
        train of currently booted environment.
        """
        data = self.middleware.call_sync('datastore.config', 'system.update')
        conf = Configuration.Configuration()
        conf.LoadTrainsConfig()

        selected = None
        trains = {}
        for name, descr in (conf.AvailableTrains() or {}).items():
            train = conf._trains.get(name)
            if train is None:
                train = Train.Train(name, descr)
            if not selected and data['upd_train'] == train.Name():
                selected = data['upd_train']
            trains[train.Name()] = {
                'description': descr,
                'sequence': train.LastSequence(),
            }
        if not data['upd_train'] or not selected:
            selected = conf.CurrentTrain()
        return {
            'trains': trains,
            'current': conf.CurrentTrain(),
            'selected': selected,
        }

    @accepts(Dict(
        'update-check-available',
        Str('train', required=False),
        required=False,
    ))
    def check_available(self, attrs=None):
        """
        Checks if there is an update available from update server.

        status:
          - REBOOT_REQUIRED: an update has already been applied
          - AVAILABLE: an update is available
          - UNAVAILABLE: no update available

        .. examples(websocket)::

          Check available update using default train:

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "update.check_available"
            }
        """

        try:
            applied = self.middleware.call_sync('cache.get', 'update.applied')
        except Exception:
            applied = False
        if applied is True:
            return {'status': 'REBOOT_REQUIRED'}

        train = (attrs or {}).get('train') or self.middleware.call_sync('update.get_trains')['selected']

        handler = CheckUpdateHandler()
        manifest = CheckForUpdates(
            diff_handler=handler.diff_call,
            handler=handler.call,
            train=train,
        )

        if not manifest:
            return {'status': 'UNAVAILABLE'}

        data = {
            'status': 'AVAILABLE',
            'changes': handler.changes,
            'notice': manifest.Notice(),
            'notes': manifest.Notes(),
        }

        conf = Configuration.Configuration()
        sys_mani = conf.SystemManifest()
        if sys_mani:
            sequence = sys_mani.Sequence()
        else:
            sequence = ''
        data['changelog'] = get_changelog(
            train,
            start=sequence,
            end=manifest.Sequence()
        )

        data['version'] = manifest.Version()
        return data

    @accepts(Str('path', null=True, default=None))
    async def get_pending(self, path=None):
        """
        Gets a list of packages already downloaded and ready to be applied.
        Each entry of the lists consists of type of operation and name of it, e.g.

          {
            "operation": "upgrade",
            "name": "baseos-11.0 -> baseos-11.1"
          }
        """
        if path is None:
            path = await self.middleware.call('update.get_update_location')
        data = []
        try:
            changes = await self.middleware.run_in_thread(Update.PendingUpdatesChanges, path)
        except (
            UpdateIncompleteCacheException, UpdateInvalidCacheException,
            UpdateBusyCacheException,
        ):
            changes = []
        if changes:
            if changes.get("Reboot", True) is False:
                for svc in changes.get("Restart", []):
                    data.append({
                        'operation': svc,
                        'name': Update.GetServiceDescription(svc),
                    })
            for new, op, old in changes['Packages']:
                if op == 'upgrade':
                    name = '%s-%s -> %s-%s' % (
                        old.Name(),
                        old.Version(),
                        new.Name(),
                        new.Version(),
                    )
                elif op == 'install':
                    name = '%s-%s' % (new.Name(), new.Version())
                else:
                    # Its unclear why "delete" would feel out new
                    # instead of old, sounds like a pkgtools bug?
                    if old:
                        name = '%s-%s' % (old.Name(), old.Version())
                    else:
                        name = '%s-%s' % (new.Name(), new.Version())

                data.append({
                    'operation': op,
                    'name': name,
                })
        return data

    @accepts(Dict(
        'update',
        Str('train', required=False),
        Bool('reboot', default=False),
        required=False,
    ))
    @job(lock='update', process=True)
    async def update(self, job, attrs=None):
        """
        Downloads (if not already in cache) and apply an update.
        """
        attrs = attrs or {}

        trains = await self.middleware.call('update.get_trains')
        train = attrs.get('train') or trains['selected']
        try:
            result = compare_trains(trains['current'], train)
        except Exception:
            self.logger.warning("Failed to compare trains %r and %r", trains['current'], train, exc_info=True)
        else:
            errors = {
                CompareTrainsResult.NIGHTLY_DOWNGRADE: textwrap.dedent("""\
                    You're not allowed to change away from the nightly train, it is considered a downgrade.
                    If you have an existing boot environment that uses that train, boot into it in order to upgrade
                    that train.
                """),
                CompareTrainsResult.MINOR_DOWNGRADE: textwrap.dedent("""\
                    Changing minor version is considered a downgrade, thus not a supported operation.
                    If you have an existing boot environment that uses that train, boot into it in order to upgrade
                    that train.
                """),
                CompareTrainsResult.MAJOR_DOWNGRADE: textwrap.dedent("""\
                    Changing major version is considered a downgrade, thus not a supported operation.
                    If you have an existing boot environment that uses that train, boot into it in order to upgrade
                    that train.
                """),
            }
            if result in errors:
                raise CallError(errors[result])

        location = await self.middleware.call('update.get_update_location')

        if attrs.get('train'):
            data = await self.middleware.call('datastore.config', 'system.update')
            if data['upd_train'] != attrs['train']:
                await self.middleware.call('datastore.update', 'system.update', data['id'], {
                    'upd_train': attrs['train']
                })

        job.set_progress(0, 'Retrieving update manifest')

        handler = UpdateHandler(self, job)

        update = Update.DownloadUpdate(
            train,
            location,
            check_handler=handler.check_handler,
            get_handler=handler.get_handler,
        )
        if update is False:
            raise ValueError('No update available')

        new_manifest = Manifest.Manifest(require_signature=True)
        new_manifest.LoadPath('{}/MANIFEST'.format(location))

        Update.ApplyUpdate(
            location,
            install_handler=handler.install_handler,
        )
        await self.middleware.call('cache.put', 'update.applied', True)

        if (
            await self.middleware.call('system.is_freenas') or
            (
                await self.middleware.call('failover.licensed') and
                await self.middleware.call('failover.status') != 'BACKUP'
            )
        ):
            await self.middleware.call('update.take_systemdataset_samba4_snapshot')

        if attrs.get('reboot'):
            await self.middleware.call('system.reboot', {'delay': 10})
        return True

    @accepts()
    @job(lock='updatedownload')
    def download(self, job):
        """
        Download updates using selected train.
        """
        train = self.middleware.call_sync('update.get_trains')['selected']
        location = self.middleware.call_sync('update.get_update_location')

        job.set_progress(0, 'Retrieving update manifest')

        handler = UpdateHandler(self, job, 100)

        Update.DownloadUpdate(
            train,
            location,
            check_handler=handler.check_handler,
            get_handler=handler.get_handler,
        )
        update = Update.CheckForUpdates(train=train, cache_dir=location)

        self.middleware.call_sync('alert.alert_source_clear_run', 'HasUpdate')

        return bool(update)

    @accepts(Str('path'))
    @job(lock='updatemanual', process=True)
    def manual(self, job, path):
        """
        Apply manual update of file `path`.
        """
        dest_extracted = os.path.join(os.path.dirname(path), '.update')
        try:
            try:
                job.set_progress(30, 'Extracting file')
                ExtractFrozenUpdate(path, dest_extracted, verbose=True)
                job.set_progress(50, 'Applying update')
                ApplyUpdate(dest_extracted)
            except Exception as e:
                self.logger.debug('Applying manual update failed', exc_info=True)
                raise CallError(str(e), errno.EFAULT)

            job.set_progress(95, 'Cleaning up')
        finally:
            if os.path.exists(path):
                os.unlink(path)

            if os.path.exists(dest_extracted):
                shutil.rmtree(dest_extracted, ignore_errors=True)

        if path.startswith(UPLOAD_LOCATION):
            self.middleware.call_sync('update.destroy_upload_location')

    @accepts(Dict(
        'updatefile',
        Str('destination', null=True),
    ))
    @job(lock='updatemanual', pipes=['input'])
    async def file(self, job, options):
        """
        Updates the system using the uploaded .tar file.

        Use null `destination` to create a temporary location.
        """

        dest = options.get('destination')

        if not dest:
            try:
                await self.middleware.call('update.create_upload_location')
                dest = '/var/tmp/firmware'
            except Exception as e:
                raise CallError(str(e))
        elif not dest.startswith('/mnt/'):
            raise CallError('Destination must reside within a pool')

        if not os.path.isdir(dest):
            raise CallError('Destination is not a directory')

        destfile = os.path.join(dest, 'manualupdate.tar')
        dest_extracted = os.path.join(dest, '.update')

        try:
            job.set_progress(10, 'Writing uploaded file to disk')
            with open(destfile, 'wb') as f:
                await self.middleware.run_in_thread(
                    shutil.copyfileobj, job.pipes.input.r, f, 1048576,
                )

            def do_update():
                try:
                    job.set_progress(30, 'Extracting uploaded file')
                    ExtractFrozenUpdate(destfile, dest_extracted, verbose=True)
                    job.set_progress(50, 'Applying update')
                    ApplyUpdate(dest_extracted)
                except Exception as e:
                    raise CallError(str(e))

            await self.middleware.run_in_thread(do_update)

            job.set_progress(95, 'Cleaning up')

        finally:
            if os.path.exists(destfile):
                os.unlink(destfile)

            if os.path.exists(dest_extracted):
                shutil.rmtree(dest_extracted, ignore_errors=True)

        if dest == '/var/tmp/firmware':
            await self.middleware.call('update.destroy_upload_location')

        job.set_progress(100, 'Update completed')

    @private
    async def get_update_location(self):
        syspath = (await self.middleware.call('systemdataset.config'))['path']
        if syspath:
            return f'{syspath}/update'
        return UPLOAD_LOCATION

    @private
    def create_upload_location(self):
        geom.scan()
        klass_label = geom.class_by_name('LABEL')
        prov = klass_label.xml.find(
            f'.//provider[name = "label/{UPLOAD_LABEL}"]/../consumer/provider'
        )
        if prov is None:
            cp = subprocess.run(
                ['mdconfig', '-a', '-t', 'swap', '-s', '2800m'],
                text=True, capture_output=True, check=False,
            )
            if cp.returncode != 0:
                raise CallError(f'Could not create memory device: {cp.stderr}')
            mddev = cp.stdout.strip()

            subprocess.run(['glabel', 'create', UPLOAD_LABEL, mddev], capture_output=True, check=False)

            cp = subprocess.run(
                ['newfs', f'/dev/label/{UPLOAD_LABEL}'],
                text=True, capture_output=True, check=False,
            )
            if cp.returncode != 0:
                raise CallError(f'Could not create temporary filesystem: {cp.stderr}')

            shutil.rmtree(UPLOAD_LOCATION, ignore_errors=True)
            os.makedirs(UPLOAD_LOCATION)

            cp = subprocess.run(
                ['mount', f'/dev/label/{UPLOAD_LABEL}', UPLOAD_LOCATION],
                text=True, capture_output=True, check=False,
            )
            if cp.returncode != 0:
                raise CallError(f'Could not mount temporary filesystem: {cp.stderr}')

        shutil.chown(UPLOAD_LOCATION, 'www', 'www')
        os.chmod(UPLOAD_LOCATION, 0o755)
        return UPLOAD_LOCATION

    @private
    def destroy_upload_location(self):
        geom.scan()
        klass_label = geom.class_by_name('LABEL')
        prov = klass_label.xml.find(
            f'.//provider[name = "label/{UPLOAD_LABEL}"]/../consumer/provider'
        )
        if prov is None:
            return
        klass_md = geom.class_by_name('MD')
        prov = klass_md.xml.find(f'.//provider[@id = "{prov.attrib["ref"]}"]/name')
        if prov is None:
            return

        mddev = prov.text

        subprocess.run(
            ['umount', f'/dev/label/{UPLOAD_LABEL}'], capture_output=True, check=False,
        )
        cp = subprocess.run(
            ['mdconfig', '-d', '-u', mddev],
            text=True, capture_output=True, check=False,
        )
        if cp.returncode != 0:
            raise CallError(f'Could not destroy memory device: {cp.stderr}')

    @private
    def take_systemdataset_samba4_snapshot(self):
        basename = self.middleware.call_sync('systemdataset.config')['basename']
        if basename is None:
            self.logger.warning('System dataset is not available, not taking snapshot')
            return

        dataset = f'{basename}/samba4'

        proc = subprocess.run(['zfs', 'list', '-t', 'snapshot', '-H', '-o', 'name', '-s', 'name', '-d', '1', dataset],
                              stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf8', errors='ignore')
        if proc.returncode != 0:
            self.logger.warning('Unable to list dataset %s snapshots: %s', dataset, proc.stderr)
            return

        snapshots = [s.split('@')[1] for s in proc.stdout.strip().split()]
        for snapshot in [s for s in snapshots if s.startswith('update--')][:-4]:
            self.logger.info('Deleting dataset %s snapshot %s', dataset, snapshot)
            subprocess.run(['zfs', 'destroy', f'{dataset}@{snapshot}'])

        current_version = "-".join(self.middleware.call_sync("system.info")["version"].split("-")[1:])
        snapshot = f'update--{datetime.utcnow().strftime("%Y-%m-%d-%H-%M")}--{current_version}'
        subprocess.run(['zfs', 'snapshot', f'{dataset}@{snapshot}'])
示例#6
0
class LDAPService(ConfigService):
    class Config:
        service = "ldap"
        datastore = 'directoryservice.ldap'
        datastore_extend = "ldap.ldap_extend"
        datastore_prefix = "ldap_"

    @private
    async def ldap_extend(self, data):
        data['hostname'] = data['hostname'].split(',') if data['hostname'] else []
        for key in ["ssl", "schema"]:
            data[key] = data[key].upper()

        if data["certificate"] is not None:
            data["cert_name"] = data['certificate']['cert_name']
            data["certificate"] = data['certificate']['id']

        if data["kerberos_realm"] is not None:
            data["kerberos_realm"] = data["kerberos_realm"]["id"]

        data['uri_list'] = await self.hostnames_to_uris(data)

        return data

    @private
    async def ldap_compress(self, data):
        data['hostname'] = ','.join(data['hostname'])
        for key in ["ssl", "schema"]:
            data[key] = data[key].lower()

        if not data['bindpw']:
            data.pop('bindpw')

        data.pop('uri_list')
        data.pop('cert_name')

        return data

    @accepts()
    async def schema_choices(self):
        """
        Returns list of available LDAP schema choices.
        """
        return await self.middleware.call('directoryservices.nss_info_choices', 'LDAP')

    @accepts()
    async def ssl_choices(self):
        """
        Returns list of SSL choices.
        """
        return await self.middleware.call('directoryservices.ssl_choices', 'LDAP')

    @private
    async def hostnames_to_uris(self, data):
        ret = []
        for h in data['hostname']:
            proto = 'ldaps' if SSL(data['ssl']) == SSL.USESSL else 'ldap'
            parsed = urlparse(f"{proto}://{h}")
            try:
                port = parsed.port
                host = parsed.netloc if not parsed.port else parsed.netloc.rsplit(':', 1)[0]
            except ValueError:
                """
                ParseResult.port will raise a ValueError if the port is not an int
                Ignore for now. ValidationError will be raised in common_validate()
                """
                host, port = h.rsplit(':', 1)

            if port is None:
                port = 636 if SSL(data['ssl']) == SSL.USESSL else 389

            uri = f"{proto}://{host}:{port}"
            ret.append(uri)

        return ret

    @private
    async def common_validate(self, new, old, verrors):
        if not new["enable"]:
            return

        if new["certificate"]:
            verrors.extend(await self.middleware.call(
                "certificate.cert_services_validation",
                new["certificate"], "ldap_update.certificate", False
            ))

        if not new["bindpw"] and new["has_samba_schema"]:
            verrors.add(
                "ldap_update.bindpw",
                "Bind credentials are required in order to use samba schema."
            )

        if not new["bindpw"] and not new["kerberos_principal"] and not new["anonbind"]:
            verrors.add(
                "ldap_update.binddn",
                "Bind credentials or kerberos keytab are required for an authenticated bind."
            )
        if new["bindpw"] and new["kerberos_principal"]:
            self.logger.warning("Simultaneous keytab and password authentication "
                                "are selected. Clearing LDAP bind password.")
            new["bindpw"] = ""

        if not new["basedn"]:
            verrors.add(
                "ldap_update.basedn",
                "The basedn parameter is required."
            )
        if not new["hostname"]:
            verrors.add(
                "ldap_update.hostname",
                "The LDAP hostname parameter is required."
            )
        for idx, uri in enumerate(new["uri_list"]):
            parsed = urlparse(uri)
            try:
                port = parsed.port

            except ValueError:
                verrors.add(f"ldap_update.hostname.{idx}",
                            f"Invalid port number: [{port}].")

    @private
    async def ldap_validate(self, data):
        for h in data['uri_list']:
            host, port = urlparse(h).netloc.rsplit(':', 1)
            await self.middleware.call('ldap.port_is_listening', host, int(port), data['dns_timeout'])

        await self.middleware.call('ldap.validate_credentials', data)

    @accepts(Dict(
        'ldap_update',
        List('hostname', required=True),
        Str('basedn', required=True),
        Str('binddn'),
        Str('bindpw', private=True),
        Bool('anonbind', default=False),
        Str('ssl', default='OFF', enum=['OFF', 'ON', 'START_TLS']),
        Int('certificate', null=True),
        Bool('validate_certificates', default=True),
        Bool('disable_freenas_cache'),
        Int('timeout', default=30),
        Int('dns_timeout', default=5),
        Int('kerberos_realm', null=True),
        Str('kerberos_principal'),
        Bool('has_samba_schema', default=False),
        Str('auxiliary_parameters', default=False, max_length=None),
        Str('schema', default='RFC2307', enum=['RFC2307', 'RFC2307BIS']),
        Bool('enable'),
        update=True
    ))
    async def do_update(self, data):
        """
        `hostname` list of ip addresses or hostnames of LDAP servers with
        which to communicate in order of preference. Failover only occurs
        if the current LDAP server is unresponsive.

        `basedn` specifies the default base DN to use when performing ldap
        operations. The base must be specified as a Distinguished Name in LDAP
        format.

        `binddn` specifies the default bind DN to use when performing ldap
        operations. The bind DN must be specified as a Distinguished Name in
        LDAP format.

        `anonbind` use anonymous authentication.

        `ssl` establish SSL/TLS-protected connections to the LDAP server(s).
        GSSAPI signing is disabled on SSL/TLS-protected connections if
        kerberos authentication is used.

        `certificate` LDAPs client certificate to be used for certificate-
        based authentication.

        `validate_certificates` specifies whether to perform checks on server
        certificates in a TLS session. If enabled, TLS_REQCERT demand is set.
        The server certificate is requested. If no certificate is provided or
        if a bad certificate is provided, the session is immediately terminated.
        If disabled, TLS_REQCERT allow is set. The server certificate is
        requested, but all errors are ignored.

        `kerberos_realm` in which the server is located. This parameter is
        only required for SASL GSSAPI authentication to the remote LDAP server.

        `kerberos_principal` kerberos principal to use for SASL GSSAPI
        authentication to the remote server. If `kerberos_realm` is specified
        without a keytab, then the `binddn` and `bindpw` are used to
        perform to obtain the ticket necessary for GSSAPI authentication.

        `timeout` specifies  a  timeout  (in  seconds) after which calls to
        synchronous LDAP APIs will abort if no response is received.

        `dns_timeout` specifies the timeout (in seconds) after which the
        poll(2)/select(2) following a connect(2) returns in case of no activity
        for openldap. For nslcd this specifies the time limit (in seconds) to
        use when connecting to the directory server. This directly impacts the
        length of time that the LDAP service tries before failing over to
        a secondary LDAP URI.

        `has_samba_schema` determines whether to configure samba to use the
        ldapsam passdb backend to provide SMB access to LDAP users. This feature
        requires the presence of Samba LDAP schema extensions on the remote
        LDAP server.
        """
        verrors = ValidationErrors()
        must_reload = False
        old = await self.config()
        new = old.copy()
        new.update(data)
        new['uri_list'] = await self.hostnames_to_uris(new)
        await self.common_validate(new, old, verrors)
        verrors.check()

        if old != new:
            must_reload = True
            if new['enable']:
                try:
                    await self.middleware.call('ldap.ldap_validate', new)
                except Exception as e:
                    raise ValidationError('ldap_update', str(e))

        await self.ldap_compress(new)
        await self.middleware.call(
            'datastore.update',
            'directoryservice.ldap',
            old['id'],
            new,
            {'prefix': 'ldap_'}
        )

        if must_reload:
            if new['enable']:
                await self.middleware.call('ldap.start')
            else:
                await self.middleware.call('ldap.stop')

        return await self.config()

    @private
    def port_is_listening(self, host, port, timeout=1):
        ret = False

        try:
            ipaddress.IPv6Address(host)
            s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
        except ipaddress.AddressValueError:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

        if timeout:
            s.settimeout(timeout)

        try:
            s.connect((host, port))
            ret = True

        except Exception as e:
            raise CallError(e)

        finally:
            s.close()

        return ret

    @private
    def validate_credentials(self, ldap=None):
        ret = False
        if ldap is None:
            ldap = self.middleware.call_sync('ldap.config')

        with LDAPQuery(conf=ldap, logger=self.logger, hosts=ldap['uri_list']) as LDAP:
            ret = LDAP.validate_credentials()

        return ret

    @private
    def get_samba_domains(self, ldap=None):
        ret = []
        if ldap is None:
            ldap = self.middleware.call_sync('ldap.config')

        with LDAPQuery(conf=ldap, logger=self.logger, hosts=ldap['uri_list']) as LDAP:
            ret = LDAP.get_samba_domains()

        return ret

    @private
    def get_root_DSE(self, ldap=None):
        """
        root DSE is defined in RFC4512, and must include the following:

        `namingContexts` naming contexts held in the LDAP sever

        `subschemaSubentry` subschema entries known by the LDAP server

        `altServer` alternative servers in case this one is unavailable

        `supportedExtension` list of supported extended operations

        `supportedControl` list of supported controls

        `supportedSASLMechnaisms` recognized Simple Authentication and Security layers
        (SASL) [RFC4422] mechanisms.

        `supportedLDAPVersion` LDAP versions implemented by the LDAP server

        In practice, this full data is not returned from many LDAP servers
        """
        ret = []
        if ldap is None:
            ldap = self.middleware.call_sync('ldap.config')

        with LDAPQuery(conf=ldap, logger=self.logger, hosts=ldap['uri_list']) as LDAP:
            ret = LDAP.get_root_DSE()

        return ret

    @private
    def get_dn(self, dn=None, ldap=None):
        """
        Outputs contents of specified DN in JSON. By default will target the basedn.
        """
        ret = []
        if ldap is None:
            ldap = self.middleware.call_sync('ldap.config')

        if dn is None:
            dn = ldap['basedn']
        with LDAPQuery(conf=ldap, logger=self.logger, hosts=ldap['uri_list']) as LDAP:
            ret = LDAP.get_dn(dn)

        return ret

    @private
    async def started(self):
        """
        Returns False if disabled, True if healthy, raises exception if faulted.
        """
        verrors = ValidationErrors()
        ldap = await self.config()
        if not ldap['enable']:
            return False

        await self.common_validate(ldap, ldap, verrors)
        try:
            verrors.check()
        except Exception:
            await self.middleware.call(
                'datastore.update',
                'directoryservice.ldap',
                ldap['id'],
                {'ldap_enable': False}
            )
            raise CallError('Automatically disabling LDAP service due to invalid configuration.',
                            errno.EINVAL)

        try:
            await asyncio.wait_for(self.middleware.call('ldap.get_root_DSE', ldap),
                                   timeout=ldap['timeout'])
        except asyncio.TimeoutError:
            raise CallError(f'LDAP status check timed out after {ldap["timeout"]} seconds.', errno.ETIMEDOUT)

        except Exception as e:
            raise CallError(e)

        return True

    @private
    async def get_workgroup(self, ldap=None):
        ret = None
        smb = await self.middleware.call('smb.config')
        if ldap is None:
            ldap = await self.config()

        try:
            ret = await asyncio.wait_for(self.middleware.call('ldap.get_samba_domains', ldap),
                                         timeout=ldap['timeout'])
        except asyncio.TimeoutError:
            raise CallError(f'ldap.get_workgroup timed out after {ldap["timeout"]} seconds.', errno.ETIMEDOUT)

        if len(ret) > 1:
            self.logger.warning('Multiple Samba Domains detected in LDAP environment '
                                'auto-configuration of workgroup map have failed: %s', ret)

        ret = ret[0]['data']['sambaDomainName'][0] if ret else []

        if ret and smb['workgroup'] != ret:
            self.logger.debug(f'Updating SMB workgroup to match the LDAP domain name [{ret}]')
            await self.middleware.call('datastore.update', 'services.cifs', smb['id'], {'cifs_srv_workgroup': ret})

        return ret

    @private
    async def set_state(self, state):
        return await self.middleware.call('directoryservices.set_state', {'ldap': state.name})

    @accepts()
    async def get_state(self):
        """
        Wrapper function for 'directoryservices.get_state'. Returns only the state of the
        LDAP service.
        """
        return (await self.middleware.call('directoryservices.get_state'))['ldap']

    @private
    def get_nslcd_status(self):
        """
        Returns internal nslcd state. nslcd will preferentially use the first LDAP server,
        and only failover if the current LDAP server is unreachable.
        """
        with NslcdClient(NlscdConst.NSLCD_ACTION_STATE_GET.value) as ctx:
            while ctx.get_response() == NlscdConst.NSLCD_RESULT_BEGIN.value:
                nslcd_status = ctx.read_string()

        return nslcd_status

    @private
    async def nslcd_cmd(self, cmd):
        nslcd = await run(['service', 'nslcd', cmd], check=False)
        if nslcd.returncode != 0:
            raise CallError(f'nslcd failed to {cmd} with errror: {nslcd.stderr.decode()}', errno.EFAULT)

    @private
    async def nslcd_status(self):
        nslcd = await run(['service', 'nslcd', 'onestatus'], check=False)
        return True if nslcd.returncode == 0 else False

    @private
    async def start(self):
        """
        Refuse to start service if the service is alreading in process of starting or stopping.
        If state is 'HEALTHY' or 'FAULTED', then stop the service first before restarting it to ensure
        that the service begins in a clean state.
        """
        ldap = await self.config()

        ldap_state = await self.middleware.call('ldap.get_state')
        if ldap_state in ['LEAVING', 'JOINING']:
            raise CallError(f'LDAP state is [{ldap_state}]. Please wait until directory service operation completes.', errno.EBUSY)

        await self.middleware.call('datastore.update', self._config.datastore, ldap['id'], {'ldap_enable': True})
        if ldap['kerberos_realm']:
            await self.middleware.call('kerberos.start')

        await self.middleware.call('etc.generate', 'rc')
        await self.middleware.call('etc.generate', 'nss')
        await self.middleware.call('etc.generate', 'ldap')
        await self.middleware.call('etc.generate', 'pam')
        has_samba_schema = True if (await self.middleware.call('ldap.get_workgroup')) else False

        if not await self.nslcd_status():
            await self.nslcd_cmd('onestart')
        else:
            await self.nslcd_cmd('onerestart')

        if has_samba_schema:
            await self.middleware.call('etc.generate', 'smb')
            await self.middleware.call('smb.store_ldap_admin_password')
            await self.middleware.call('service.restart', 'cifs')

        await self.set_state(DSStatus['HEALTHY'])
        await self.middleware.call('ldap.fill_cache')

    @private
    async def stop(self):
        ldap = await self.config()
        await self.middleware.call('datastore.update', self._config.datastore, ldap['id'], {'ldap_enable': False})
        await self.set_state(DSStatus['LEAVING'])
        await self.middleware.call('etc.generate', 'rc')
        await self.middleware.call('etc.generate', 'nss')
        await self.middleware.call('etc.generate', 'ldap')
        await self.middleware.call('etc.generate', 'pam')
        if ldap['has_samba_schema']:
            await self.middleware.call('etc.generate', 'smb')
            await self.middleware.call('service.restart', 'cifs')
        await self.middleware.call('cache.pop', 'LDAP_cache')
        await self.nslcd_cmd('onestop')
        await self.set_state(DSStatus['DISABLED'])

    @private
    @job(lock='fill_ldap_cache')
    def fill_cache(self, job, force=False):
        user_next_index = group_next_index = 100000000
        cache_data = {'users': {}, 'groups': {}}

        if self.middleware.call_sync('cache.has_key', 'LDAP_cache') and not force:
            raise CallError('LDAP cache already exists. Refusing to generate cache.')

        self.middleware.call_sync('cache.pop', 'LDAP_cache')

        if (self.middleware.call_sync('ldap.config'))['disable_freenas_cache']:
            self.middleware.call_sync('cache.put', 'LDAP_cache', cache_data)
            self.logger.debug('LDAP cache is disabled. Bypassing cache fill.')
            return

        pwd_list = pwd.getpwall()
        grp_list = grp.getgrall()

        local_uid_list = list(u['uid'] for u in self.middleware.call_sync('user.query'))
        local_gid_list = list(g['gid'] for g in self.middleware.call_sync('group.query'))

        for u in pwd_list:
            is_local_user = True if u.pw_uid in local_uid_list else False
            if is_local_user:
                continue

            cache_data['users'].update({u.pw_name: {
                'id': user_next_index,
                'uid': u.pw_uid,
                'username': u.pw_name,
                'unixhash': None,
                'smbhash': None,
                'group': {},
                'home': '',
                'shell': '',
                'full_name': u.pw_gecos,
                'builtin': False,
                'email': '',
                'password_disabled': False,
                'locked': False,
                'sudo': False,
                'microsoft_account': False,
                'attributes': {},
                'groups': [],
                'sshpubkey': None,
                'local': False
            }})
            user_next_index += 1

        for g in grp_list:
            is_local_user = True if g.gr_gid in local_gid_list else False
            if is_local_user:
                continue

            cache_data['groups'].update({g.gr_name: {
                'id': group_next_index,
                'gid': g.gr_gid,
                'group': g.gr_name,
                'builtin': False,
                'sudo': False,
                'users': [],
                'local': False
            }})
            group_next_index += 1

        self.middleware.call_sync('cache.put', 'LDAP_cache', cache_data)
        self.middleware.call_sync('dscache.backup')

    @private
    async def get_cache(self):
        if not await self.middleware.call('cache.has_key', 'LDAP_cache'):
            await self.middleware.call('ldap.fill_cache')
            self.logger.debug('cache fill is in progress.')
            return {'users': {}, 'groups': {}}
        return await self.middleware.call('cache.get', 'LDAP_cache')
class InitShutdownScriptService(CRUDService):

    class Config:
        datastore = 'tasks.initshutdown'
        datastore_prefix = 'ini_'
        datastore_extend = 'initshutdownscript.init_shutdown_script_extend'

    @accepts(Dict(
        'init_shutdown_script_create',
        Str('type', enum=['COMMAND', 'SCRIPT'], required=True),
        Str('command', null=True),
        Str('script_text', null=True),
        File('script', null=True),
        Str('when', enum=['PREINIT', 'POSTINIT', 'SHUTDOWN'], required=True),
        Bool('enabled', default=True),
        Int('timeout', default=10),
        Str('comment', default='', validators=[Range(max=255)]),
        register=True,
    ))
    async def do_create(self, data):
        """
        Create an initshutdown script task.

        `type` indicates if a command or script should be executed at `when`.

        There are three choices for `when`:

        1) PREINIT - This is early in the boot process before all the services / rc scripts have started
        2) POSTINIT - This is late in the boot process when most of the services / rc scripts have started
        3) SHUTDOWN - This is on shutdown

        `timeout` is an integer value which indicates time in seconds which the system should wait for the execution
        of script/command. It should be noted that a hard limit for a timeout is configured by the base OS, so when
        a script/command is set to execute on SHUTDOWN, the hard limit configured by the base OS is changed adding
        the timeout specified by script/command so it can be ensured that it executes as desired and is not interrupted
        by the base OS's limit.
        """
        await self.validate(data, 'init_shutdown_script_create')

        await self.init_shutdown_script_compress(data)

        data['id'] = await self.middleware.call(
            'datastore.insert',
            self._config.datastore,
            data,
            {'prefix': self._config.datastore_prefix}
        )

        return await self._get_instance(data['id'])

    @accepts(Int('id'), Patch(
        'init_shutdown_script_create',
        'init_shutdown_script_update',
        ('attr', {'update': True}),
    ))
    async def do_update(self, id, data):
        """
        Update initshutdown script task of `id`.
        """
        old = await self._get_instance(id)
        new = old.copy()
        new.update(data)

        await self.validate(new, 'init_shutdown_script_update')

        await self.init_shutdown_script_compress(new)

        await self.middleware.call(
            'datastore.update',
            self._config.datastore,
            id,
            new,
            {'prefix': self._config.datastore_prefix}
        )

        return await self._get_instance(new['id'])

    @accepts(Int('id'))
    async def do_delete(self, id):
        """
        Delete init/shutdown task of `id`.
        """
        return await self.middleware.call(
            'datastore.delete',
            self._config.datastore,
            id
        )

    @private
    async def init_shutdown_script_extend(self, data):
        data['type'] = data['type'].upper()
        data['when'] = data['when'].upper()

        return data

    @private
    async def init_shutdown_script_compress(self, data):
        data['type'] = data['type'].lower()
        data['when'] = data['when'].lower()

        return data

    @private
    async def validate(self, data, schema_name):
        verrors = ValidationErrors()

        if data['type'] == 'COMMAND':
            if not data.get('command'):
                verrors.add(f'{schema_name}.command', 'This field is required')
            else:
                data['script_text'] = ''
                data['script'] = ''

        if data['type'] == 'SCRIPT':
            if data.get('script') and data.get('script_text'):
                verrors.add(f'{schema_name}.script', 'Only one of two fields should be provided')
            elif not data.get('script') and not data.get('script_text'):
                # IDEA may be it's worth putting both fields validations errors to verrors
                # e.g.
                # verrors.add(f'{schema_name}.script', 'This field is required')
                # verrors.add(f'{schema_name}.script_text', 'This field is required')
                verrors.add(f'{schema_name}.script', "Either 'script' or 'script_text' field is required")
            elif data.get('script') and not data.get('script_text'):
                data['command'] = ''
                data['script_text'] = ''
            else:
                data['command'] = ''
                data['script'] = ''

        if verrors:
            raise verrors

    @private
    async def execute_task(self, task):
        task_type = task['type']
        cmd = None
        tmp_script = None

        if task_type == 'COMMAND':
            cmd = task['command']
        elif task_type == 'SCRIPT' and task['script_text']:
            _, tmp_script = tempfile.mkstemp(text=True)
            os.chmod(tmp_script, stat.S_IRWXU)
            with open(tmp_script, 'w') as f:
                f.write(task['script_text'])
            cmd = f'exec {tmp_script}'
        elif os.path.exists(task['script'] or '') and os.access(task['script'], os.X_OK):
            cmd = f'exec {task["script"]}'

        try:
            if cmd:
                proc = await Popen(
                    cmd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    shell=True,
                    close_fds=True
                )
                stdout, stderr = await proc.communicate()

                if proc.returncode:
                    if task_type == 'COMMAND':
                        cmd = task['command']
                    elif task_type == 'SCRIPT' and task['script_text']:
                        cmd = task['comment']
                    elif task_type == 'SCRIPT' and task['script']:
                        cmd = task['script']
                    else:
                        cmd = ''
                    self.middleware.logger.debug(
                        'Execution failed for '
                        f'{task_type} {cmd}: {stdout.decode()}'
                    )
        except Exception as error:
            if task_type == 'SCRIPT' and task['script_text']:
                cmd = task['comment']
            self.middleware.logger.debug(
                f'{task["type"]} {cmd}: {error!r}'
            )
        finally:
            if tmp_script and os.path.exists(tmp_script):
                os.unlink(tmp_script)

    @private
    @accepts(
        Str('when')
    )
    @job()
    async def execute_init_tasks(self, job, when):

        tasks = await self.middleware.call(
            'initshutdownscript.query', [
                ['enabled', '=', True],
                ['when', '=', when]
            ])

        for i, task in enumerate(tasks):
            try:
                await asyncio.wait_for(self.execute_task(task), timeout=task['timeout'])
            except asyncio.TimeoutError:
                if task['type'] == 'COMMAND':
                    cmd = task['command']
                elif task['type'] == 'SCRIPT' and task['script_text']:
                    cmd = task['comment']
                elif task['type'] == 'SCRIPT' and task['script']:
                    cmd = task['script']
                else:
                    cmd = ''
                self.middleware.logger.debug(f'{task["type"]} {cmd} timed out')
            finally:
                job.set_progress((100 / len(tasks)) * (i + 1))

        job.set_progress(100, f'Completed tasks for {when}')
示例#8
0
文件: jail.py 项目: tejp/freenas
class JailService(CRUDService):
    class Config:
        process_pool = True

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # We want debug for jails starting/stopping
        os.environ['IOCAGE_DEBUG'] = 'TRUE'

    # FIXME: foreign schemas cannot be referenced when
    # using `process_pool`
    # @filterable
    @accepts(
        List('query-filters', default=[]),
        Dict('query-options', additional_attrs=True),
    )
    def query(self, filters=None, options=None):
        """
        Query all jails with `query-filters` and `query-options`.
        """
        options = options or {}
        jail_identifier = None
        jails = []

        if filters and len(filters) == 1 and list(
                filters[0][:2]) == ['host_hostuuid', '=']:
            jail_identifier = filters[0][2]

        recursive = False if jail_identifier == 'default' else True

        try:
            jail_dicts = ioc.IOCage(jail=jail_identifier).get(
                'all', recursive=recursive)

            if jail_identifier == 'default':
                jail_dicts['host_hostuuid'] = 'default'
                jails.append(jail_dicts)
            else:
                for jail in jail_dicts:
                    jail = list(jail.values())[0]
                    jail['id'] = jail['host_hostuuid']
                    if jail['dhcp'] == 'on':
                        uuid = jail['host_hostuuid']

                        if jail['state'] == 'up':
                            interface = jail['interfaces'].split(',')[0].split(
                                ':')[0]
                            if interface == 'vnet0':
                                # Inside jails they are epair0b
                                interface = 'epair0b'
                            ip4_cmd = [
                                'jexec', f'ioc-{uuid}', 'ifconfig', interface,
                                'inet'
                            ]
                            try:
                                out = su.check_output(ip4_cmd)
                                out = out.splitlines()[2].split()[1].decode()
                                jail['ip4_addr'] = f'{interface}|{out}'
                            except (su.CalledProcessError, IndexError):
                                jail['ip4_addr'] = f'{interface}|ERROR'
                        else:
                            jail['ip4_addr'] = 'DHCP (not running)'
                    jails.append(jail)
        except ioc_exceptions.JailMisconfigured as e:
            self.logger.error(e, exc_info=True)
        except BaseException:
            # Brandon is working on fixing this generic except, till then I
            # am not going to make the perfect the enemy of the good enough!
            self.logger.debug('Failed to get list of jails', exc_info=True)

        return filter_list(jails, filters, options)

    query._fiterable = True

    @accepts(
        Dict("options", Str("release", required=True), Str("template"),
             Str("pkglist"), Str("uuid", required=True),
             Bool("basejail", default=False), Bool("empty", default=False),
             Bool("short", default=False), List("props", default=[]),
             Bool('https', default=True)))
    async def do_create(self, options):
        """Creates a jail."""
        # Typically one would return the created jail's id in this
        # create call BUT since jail creation may or may not involve
        # fetching a release, which in turn could be time consuming
        # and could then block for a long time. This dictates that we
        # make it a job, but that violates the principle that CRUD methods
        # are not jobs as yet, so I settle on making this a wrapper around
        # the main job that calls this and return said job's id instead of
        # the created jail's id

        return await self.middleware.call('jail.create_job', options)

    @private
    @job(lock=lambda args: f'jail_create:{args[-1]["uuid"]}')
    def create_job(self, job, options):
        verrors = ValidationErrors()
        uuid = options["uuid"]

        job.set_progress(0, f'Creating: {uuid}')

        try:
            self.check_jail_existence(uuid, skip=False)

            verrors.add('uuid', f'A jail with name {uuid} already exists')
            raise verrors
        except CallError:
            # A jail does not exist with the provided name, we can create one
            # now

            verrors = self.common_validation(verrors, options)

            if verrors:
                raise verrors

            job.set_progress(20, 'Initial validation complete')

        iocage = ioc.IOCage(skip_jails=True)

        release = options["release"]
        template = options.get("template", False)
        pkglist = options.get("pkglist", None)
        basejail = options["basejail"]
        empty = options["empty"]
        short = options["short"]
        props = options["props"]
        pool = IOCJson().json_get_value("pool")
        iocroot = IOCJson(pool).json_get_value("iocroot")
        https = options.get('https', True)

        if template:
            release = template

        if (not os.path.isdir(f'{iocroot}/releases/{release}') and not template
                and not empty):
            job.set_progress(50, f'{release} missing, calling fetch')
            self.middleware.call_sync('jail.fetch', {
                "release": release,
                "https": https
            },
                                      job=True)

        err, msg = iocage.create(release,
                                 props,
                                 0,
                                 pkglist,
                                 template=template,
                                 short=short,
                                 _uuid=uuid,
                                 basejail=basejail,
                                 empty=empty)

        if err:
            raise CallError(msg)

        job.set_progress(100, f'Created: {uuid}')

        return True

    @private
    def validate_ips(self,
                     verrors,
                     options,
                     schema='options.props',
                     exclude=None):
        for item in options['props']:
            for f in ('ip4_addr', 'ip6_addr'):
                # valid ip values can be
                # 1) none
                # 2) interface|accept_rtadv
                # 3) interface|ip/netmask
                # 4) interface|ip
                # 5) ip/netmask
                # 6) ip
                # 7) All the while making sure that the above can be mixed with each other using ","
                # we explicitly check these
                if f in item:
                    for ip in map(
                            lambda ip: ip.split('|', 1)[-1].split('/')[0],
                            filter(
                                lambda v: v != 'none' and v.split('|')[-1] !=
                                'accept_rtadv',
                                item.split('=')[1].split(','))):
                        try:
                            IpInUse(self.middleware, exclude)(ip)
                        except ValueError as e:
                            verrors.add(f'{schema}.{f}', str(e))

    @accepts(Str("jail"),
             Dict(
                 "options",
                 Bool("plugin", default=False),
                 additional_attrs=True,
             ))
    def do_update(self, jail, options):
        """Sets a jail property."""
        plugin = options.pop("plugin")
        _, _, iocage = self.check_jail_existence(jail)

        name = options.pop("name", None)

        verrors = ValidationErrors()

        jail = self.query([['id', '=', jail]], {'get': True})

        verrors = self.common_validation(verrors, options, True, jail)

        if name is not None and plugin:
            verrors.add('options.plugin',
                        'Cannot be true while trying to rename')

        if verrors:
            raise verrors

        for prop, val in options.items():
            p = f"{prop}={val}"

            try:
                iocage.set(p, plugin)
            except RuntimeError as err:
                raise CallError(err)

        if name:
            iocage.rename(name)

        return True

    @private
    def common_validation(self, verrors, options, update=False, jail=None):
        if not update:
            # Ensure that api call conforms to format set by iocage for props
            # Example 'key=value'

            for value in options['props']:
                if '=' not in value:
                    verrors.add(
                        'options.props',
                        'Please follow the format specified by iocage for api calls'
                        'e.g "key=value"')
                    break

            if verrors:
                raise verrors

            # normalise vnet mac address
            # expected format here is 'vnet0_mac=00-D0-56-F2-B5-12,00-D0-56-F2-B5-13'
            vnet_macs = {
                f.split('=')[0]: f.split('=')[1]
                for f in options['props']
                if any(f'vnet{i}_mac' in f.split('=')[0] for i in range(0, 4))
            }

            self.validate_ips(verrors, options)
        else:
            vnet_macs = {
                key: value
                for key, value in options.items()
                if any(f'vnet{i}_mac' in key for i in range(0, 4))
            }

            exclude_ips = [
                ip.split('|')[1].split('/')[0]
                if '|' in ip else ip.split('/')[0]
                for f in ('ip4_addr', 'ip6_addr') for ip in jail[f].split(',')
                if ip not in ('none', 'DHCP (not running)')
            ]

            self.validate_ips(
                verrors, {'props': [f'{k}={v}' for k, v in options.items()]},
                'options', exclude_ips)

        # validate vnetX_mac addresses
        for key, value in vnet_macs.items():
            if value and value != 'none':
                value = value.replace(',', ' ')
                try:
                    for mac in value.split():
                        MACAddr()(mac)

                    if (len(value.split()) != 2
                            or any(value.split().count(v) > 1
                                   for v in value.split())):
                        raise ValueError('Exception')
                except ValueError:
                    verrors.add(
                        key, 'Please Enter two valid and different '
                        f'space/comma-delimited MAC addresses for {key}.')

        return verrors

    @accepts(Str("jail"))
    def do_delete(self, jail):
        """Takes a jail and destroys it."""
        _, _, iocage = self.check_jail_existence(jail)

        # TODO: Port children checking, release destroying.
        iocage.destroy_jail()

        return True

    @private
    def check_dataset_existence(self):
        try:
            IOCCheck()
        except ioc_exceptions.PoolNotActivated as e:
            raise CallError(e, errno=errno.ENOENT)

    @private
    def check_jail_existence(self, jail, skip=True, callback=None):
        """Wrapper for iocage's API, as a few commands aren't ported to it"""
        try:
            if callback is not None:
                iocage = ioc.IOCage(callback=callback,
                                    skip_jails=skip,
                                    jail=jail)
            else:
                iocage = ioc.IOCage(skip_jails=skip, jail=jail)
            jail, path = iocage.__check_jail_existence__()
        except (SystemExit, RuntimeError):
            raise CallError(f"jail '{jail}' not found!")

        return jail, path, iocage

    @accepts()
    def get_activated_pool(self):
        """Returns the activated pool if there is one, or None"""
        try:
            pool = ioc.IOCage(skip_jails=True).get('', pool=True)
        except RuntimeError as e:
            raise CallError(f'Error occurred getting activated pool: {e}')
        except (ioc_exceptions.PoolNotActivated, FileNotFoundError):
            self.check_dataset_existence()

            try:
                pool = ioc.IOCage(skip_jails=True).get('', pool=True)
            except ioc_exceptions.PoolNotActivated:
                pool = None

        return pool

    @accepts(
        Dict(
            'options', Str('release'),
            Str('server', default='download.freebsd.org'),
            Str('user', default='anonymous'),
            Str('password', default='anonymous@'),
            Str('name', default=None, null=True), Bool('accept', default=True),
            Bool('https', default=True), List('props', default=[]),
            List('files',
                 default=['MANIFEST', 'base.txz', 'lib32.txz', 'doc.txz']),
            Str('branch', default=None, null=True)))
    @job(lock=lambda args: f"jail_fetch:{args[-1]}")
    def fetch(self, job, options):
        """Fetches a release or plugin."""
        fetch_output = {'install_notes': []}
        release = options.get('release', None)
        https = options.pop('https', False)

        post_install = False

        verrors = ValidationErrors()

        self.validate_ips(verrors, options)

        if verrors:
            raise verrors

        def progress_callback(content, exception):
            msg = content['message'].strip('\r\n')
            rel_up = f'* Updating {release} to the latest patch level... '
            nonlocal post_install

            if options['name'] is None:
                if 'Downloading : base.txz' in msg and '100%' in msg:
                    job.set_progress(5, msg)
                elif 'Downloading : lib32.txz' in msg and '100%' in msg:
                    job.set_progress(10, msg)
                elif 'Downloading : doc.txz' in msg and '100%' in msg:
                    job.set_progress(15, msg)
                elif 'Downloading : src.txz' in msg and '100%' in msg:
                    job.set_progress(20, msg)
                if 'Extracting: base.txz' in msg:
                    job.set_progress(25, msg)
                elif 'Extracting: lib32.txz' in msg:
                    job.set_progress(50, msg)
                elif 'Extracting: doc.txz' in msg:
                    job.set_progress(75, msg)
                elif 'Extracting: src.txz' in msg:
                    job.set_progress(90, msg)
                elif rel_up in msg:
                    job.set_progress(95, msg)
                else:
                    job.set_progress(None, msg)
            else:
                if post_install:
                    for split_msg in msg.split('\n'):
                        fetch_output['install_notes'].append(split_msg)

                if '  These pkgs will be installed:' in msg:
                    job.set_progress(50, msg)
                elif 'Installing plugin packages:' in msg:
                    job.set_progress(75, msg)
                elif 'Running post_install.sh' in msg:
                    job.set_progress(90, msg)
                    # Sets each message going forward as important to the user
                    post_install = True
                else:
                    job.set_progress(None, msg)

        self.check_dataset_existence()  # Make sure our datasets exist.
        start_msg = f'{release} being fetched'
        final_msg = f'{release} fetched'

        iocage = ioc.IOCage(callback=progress_callback, silent=False)

        if options["name"] is not None:
            pool = IOCJson().json_get_value('pool')
            iocroot = IOCJson(pool).json_get_value('iocroot')

            options["plugin_file"] = True
            start_msg = 'Starting plugin install'
            final_msg = f"Plugin: {options['name']} installed"
        elif options['name'] is None and https:
            if 'https' not in options['server']:
                options['server'] = f'https://{options["server"]}'

        options["accept"] = True

        job.set_progress(0, start_msg)
        iocage.fetch(**options)

        if post_install and options['name'] is not None:
            plugin_manifest = pathlib.Path(
                f'{iocroot}/.plugin_index/{options["name"]}.json')
            plugin_json = json.loads(plugin_manifest.read_text())
            schema_version = plugin_json.get('plugin_schema', '1')

            if schema_version.isdigit() and int(schema_version) >= 2:
                plugin_output = pathlib.Path(
                    f'{iocroot}/jails/{options["name"]}/root/root/PLUGIN_INFO')

                if plugin_output.is_file():
                    # Otherwise it will be the verbose output from the
                    # post_install script
                    fetch_output['install_notes'] = [
                        x for x in plugin_output.read_text().split('\n') if x
                    ]

                    # This is to get the admin URL and such
                    fetch_output['install_notes'] += job.progress[
                        'description'].split('\n')

        job.set_progress(100, final_msg)

        return fetch_output

    @accepts(
        Str('resource', enum=['RELEASE', 'TEMPLATE', 'PLUGIN', 'BRANCHES']),
        Bool('remote', default=False), Bool('want_cache', default=True),
        Str('branch', default=None))
    def list_resource(self, resource, remote, want_cache, branch):
        """Returns a JSON list of the supplied resource on the host"""
        self.check_dataset_existence()  # Make sure our datasets exist.
        iocage = ioc.IOCage(skip_jails=True)
        resource = "base" if resource == "RELEASE" else resource.lower()

        if resource == "plugin":
            if remote:
                if want_cache:
                    try:
                        resource_list = self.middleware.call_sync(
                            'cache.get', 'iocage_remote_plugins')

                        return resource_list
                    except KeyError:
                        pass

                resource_list = iocage.fetch(list=True,
                                             plugins=True,
                                             header=False,
                                             branch=branch)
            else:
                resource_list = iocage.list("all", plugin=True)
                pool = IOCJson().json_get_value("pool")
                iocroot = IOCJson(pool).json_get_value("iocroot")
                index_path = f'{iocroot}/.plugin_index/INDEX'

                if not pathlib.Path(index_path).is_file():
                    index_json = None

                    for plugin in resource_list:
                        plugin += ['N/A', 'N/A']

                    return resource_list
                else:
                    index_fd = open(index_path, 'r')
                    index_json = json.load(index_fd)

            for plugin in resource_list:
                for i, elem in enumerate(plugin):
                    # iocage returns - for None
                    plugin[i] = elem if elem != "-" else None

                if remote:
                    pv = self.get_plugin_version(plugin[2])
                else:
                    pv = self.get_local_plugin_version(plugin[1], index_json,
                                                       iocroot)

                resource_list[resource_list.index(plugin)] = plugin + pv

            if remote:
                self.middleware.call_sync('cache.put', 'iocage_remote_plugins',
                                          resource_list, 86400)
            else:
                index_fd.close()
        elif resource == "base":
            try:
                if remote:
                    resource_list = self.middleware.call_sync(
                        'cache.get', 'iocage_remote_releases')

                    return resource_list
            except KeyError:
                pass

            resource_list = iocage.fetch(list=True, remote=remote, http=True)

            if remote:
                self.middleware.call_sync('cache.put',
                                          'iocage_remote_releases',
                                          resource_list, 86400)
        elif resource == 'branches':
            official_branches = requests.get(
                'https://api.github.com/repos/freenas/iocage-ix-plugins/'
                'branches')
            official_branches.raise_for_status()
            resource_list = {
                'official': [b['name'] for b in official_branches.json()]
            }
        else:
            resource_list = iocage.list(resource)

        return resource_list

    @accepts(Str("action", enum=["START", "STOP", "RESTART"]))
    def rc_action(self, action):
        """Does specified action on rc enabled (boot=on) jails"""
        iocage = ioc.IOCage(rc=True)

        try:
            if action == "START":
                iocage.start()
            elif action == "STOP":
                iocage.stop()
            else:
                iocage.restart()
        except BaseException as e:
            raise CallError(str(e))

        return True

    @accepts(Str('jail'))
    @job(lock=lambda args: f'jail_start:{args[-1]}')
    def start(self, job, jail):
        """Takes a jail and starts it."""
        uuid, _, iocage = self.check_jail_existence(jail)
        status, _ = IOCList.list_get_jid(uuid)

        if not status:
            try:
                iocage.start()
            except BaseException as e:
                raise CallError(str(e))

        return True

    @accepts(Str("jail"), Bool('force', default=False))
    @job(lock=lambda args: f'jail_stop:{args[-1]}')
    def stop(self, job, jail, force):
        """Takes a jail and stops it."""
        uuid, _, iocage = self.check_jail_existence(jail)
        status, _ = IOCList.list_get_jid(uuid)

        if status:
            try:
                iocage.stop(force=force)
            except BaseException as e:
                raise CallError(str(e))

            return True

    @accepts(Str('jail'))
    @job(lock=lambda args: f"jail_restart:{args[-1]}")
    def restart(self, job, jail):
        """Takes a jail and restarts it."""
        uuid, _, iocage = self.check_jail_existence(jail)
        status, _ = IOCList.list_get_jid(uuid)

        if status:
            try:
                iocage.stop()
            except BaseException as e:
                raise CallError(str(e))

        try:
            iocage.start()
        except BaseException as e:
            raise CallError(str(e))

        return True

    @private
    def get_iocroot(self):
        pool = IOCJson().json_get_value("pool")
        return IOCJson(pool).json_get_value("iocroot")

    @accepts(Str("jail"),
             Dict(
                 "options",
                 Str("action",
                     enum=["ADD", "EDIT", "REMOVE", "REPLACE", "LIST"],
                     required=True),
                 Str("source"),
                 Str("destination"),
                 Str("fstype", default='nullfs'),
                 Str("fsoptions", default='ro'),
                 Str("dump", default='0'),
                 Str("pass", default='0'),
                 Int("index", default=None),
             ))
    def fstab(self, jail, options):
        """Manipulate a jails fstab"""
        uuid, _, iocage = self.check_jail_existence(jail, skip=False)
        status, jid = IOCList.list_get_jid(uuid)
        action = options['action'].lower()
        index = options.get('index')

        if status and action != 'list':
            raise CallError(
                f'{jail} should not be running when adding a mountpoint')

        verrors = ValidationErrors()

        source = options.get('source')
        if source:
            if not os.path.exists(source):
                verrors.add('options.source',
                            'Provided path for source does not exist')
        elif (not source and index is None) and action != 'list':
            verrors.add('options.source', 'Provide a source path')

        destination = options.get('destination')
        if destination:
            destination = f'/{destination}' if destination[0] != '/' else \
                destination
            dst = f'{self.get_iocroot()}/jails/{jail}/root'
            if dst not in destination:
                destination = f'{dst}{destination}'

            if os.path.exists(destination):
                if not os.path.isdir(destination):
                    verrors.add(
                        'options.destination',
                        'Destination is not a directory, please provide a'
                        ' valid destination')
                elif os.listdir(destination):
                    verrors.add('options.destination',
                                'Destination directory should be empty')
            else:
                os.makedirs(destination)
        elif (not destination and index is None) and action != 'list':
            verrors.add('options.destination', 'Provide a destination path')

        if index is not None:
            # Setup defaults for library
            source = ''
            destination = ''

        if action != 'list':
            for f in options:
                if not options.get(f) and f not in ('index', ):
                    verrors.add(f'options.{f}', 'This field is required')

        fstype = options.get('fstype')
        fsoptions = options.get('fsoptions')
        dump = options.get('dump')
        _pass = options.get('pass')

        if action == 'replace' and index is None:
            verrors.add('options.index',
                        'Index must not be None when replacing fstab entry')

        if verrors:
            raise verrors

        try:
            _list = iocage.fstab(action,
                                 source,
                                 destination,
                                 fstype,
                                 fsoptions,
                                 dump,
                                 _pass,
                                 index=index)
        except ioc_exceptions.ValidationFailed as e:
            # CallError uses strings, the exception message may not always be a
            # list.
            if not isinstance(e.message, str) and isinstance(
                    e.message, Iterable):
                e.message = '\n'.join(e.message)

            self.logger.error(f'{e!r}')
            raise CallError(e.message)

        if action == "list":
            split_list = {}
            system_mounts = ('/root/bin', '/root/boot', '/root/lib',
                             '/root/libexec', '/root/rescue', '/root/sbin',
                             '/root/usr/bin', '/root/usr/include',
                             '/root/usr/lib', '/root/usr/libexec',
                             '/root/usr/sbin', '/root/usr/share',
                             '/root/usr/libdata', '/root/usr/lib32')

            for i in _list:
                fstab_entry = i[1].split()
                _fstab_type = 'SYSTEM' if fstab_entry[0].endswith(
                    system_mounts) else 'USER'

                split_list[i[0]] = {'entry': fstab_entry, 'type': _fstab_type}

            return split_list

        return True

    @accepts(Str("pool"))
    def activate(self, pool):
        """Activates a pool for iocage usage, and deactivates the rest."""
        zfs = libzfs.ZFS(history=True, history_prefix="<iocage>")
        pools = zfs.pools
        prop = "org.freebsd.ioc:active"
        activated = False

        for _pool in pools:
            if _pool.name == pool:
                ds = zfs.get_dataset(_pool.name)
                ds.properties[prop] = libzfs.ZFSUserProperty("yes")
                activated = True
            else:
                ds = zfs.get_dataset(_pool.name)
                ds.properties[prop] = libzfs.ZFSUserProperty("no")

        return activated

    @accepts(Str("ds_type", enum=["ALL", "JAIL", "TEMPLATE", "RELEASE"]))
    def clean(self, ds_type):
        """Cleans all iocage datasets of ds_type"""

        if ds_type == "JAIL":
            IOCClean().clean_jails()
        elif ds_type == "ALL":
            IOCClean().clean_all()
        elif ds_type == "TEMPLATE":
            IOCClean().clean_templates()

        return True

    @accepts(Str("jail"), List("command", required=True),
             Dict("options", Str("host_user", default="root"),
                  Str("jail_user")))
    @job(lock=lambda args: f"jail_exec:{args[-1]}")
    def exec(self, job, jail, command, options):
        """Issues a command inside a jail."""
        _, _, iocage = self.check_jail_existence(jail, skip=False)

        host_user = options["host_user"]
        jail_user = options.get("jail_user", None)

        if isinstance(command[0], list):
            # iocage wants a flat list, not a list inside a list
            command = list(itertools.chain.from_iterable(command))

        # We may be getting ';', '&&' and so forth. Adding the shell for
        # safety.
        if len(command) == 1:
            command = ["/bin/sh", "-c"] + command

        host_user = "" if jail_user and host_user == "root" else host_user
        try:
            msg = iocage.exec(command,
                              host_user,
                              jail_user,
                              start_jail=True,
                              msg_return=True)
        except BaseException as e:
            raise CallError(str(e))

        return '\n'.join(msg)

    @accepts(Str("jail"))
    @job(lock=lambda args: f"jail_update:{args[-1]}")
    def update_to_latest_patch(self, job, jail):
        """Updates specified jail to latest patch level."""
        job.set_progress(0, f'Updating {jail}')
        msg_queue = deque(maxlen=10)

        def progress_callback(content, exception):
            msg = content['message'].strip('\n')
            msg_queue.append(msg)
            final_msg = '\n'.join(msg_queue)

            if 'Inspecting system... done' in msg:
                job.set_progress(20)
            elif 'Preparing to download files... done.' in msg:
                job.set_progress(50)
            elif 'Applying patches... done.' in msg:
                job.set_progress(75)
            elif 'Installing updates... done.' in msg:
                job.set_progress(90)
            elif f'{jail} has been updated successfully' in msg:
                job.set_progress(100)

            job.set_progress(None, description=final_msg)

        _, _, iocage = self.check_jail_existence(jail,
                                                 callback=progress_callback)
        iocage.update()

        return True

    @accepts(Str("jail"),
             Dict("options", Str("release", required=False),
                  Bool("plugin", default=False)))
    @job(lock=lambda args: f"jail_upgrade:{args[-1]}")
    def upgrade(self, job, jail, options):
        """Upgrades specified jail to specified RELEASE."""
        verrors = ValidationErrors()
        release = options.get('release', None)
        plugin = options['plugin']

        if release is None and not plugin:
            verrors.add('options.release',
                        'Must not be None if options.plugin is False.')
            raise verrors

        job.set_progress(0, f'Upgrading {jail}')
        msg_queue = deque(maxlen=10)

        def progress_callback(content, exception):
            msg = content['message'].strip('\n')
            msg_queue.append(msg)
            final_msg = '\n'.join(msg_queue)

            if plugin:
                plugin_progress(job, msg)
            else:
                jail_progress(job, msg)

            job.set_progress(None, description=final_msg)

        def plugin_progress(job, msg):
            if 'Snapshotting' in msg:
                job.set_progress(20)
            elif 'Updating plugin INDEX' in msg:
                job.set_progress(40)
            elif 'Running upgrade' in msg:
                job.set_progress(70)
            elif 'Installing plugin packages' in msg:
                job.set_progress(90)
            elif f'{jail} successfully upgraded' in msg:
                job.set_progress(100)

        def jail_progress(job, msg):
            if 'Inspecting system' in msg:
                job.set_progress(20)
            elif 'Preparing to download files' in msg:
                job.set_progress(50)
            elif 'Applying patches' in msg:
                job.set_progress(75)
            elif 'Installing updates' in msg:
                job.set_progress(90)
            elif f'{jail} successfully upgraded' in msg:
                job.set_progress(100)

        _, _, iocage = self.check_jail_existence(jail,
                                                 callback=progress_callback)
        iocage.upgrade(release=release)

        return True

    @accepts(Str("jail"))
    @job(lock=lambda args: f"jail_export:{args[-1]}")
    def export(self, job, jail):
        """Exports jail to zip file"""
        uuid, path, _ = self.check_jail_existence(jail)
        status, jid = IOCList.list_get_jid(uuid)
        started = False

        if status:
            self.middleware.call_sync('jail.stop', jail, job=True)
            started = True

        IOCImage().export_jail(uuid, path)

        if started:
            self.middleware.call_sync('jail.start', jail, job=True)

        return True

    @accepts(Str("jail"))
    @job(lock=lambda args: f"jail_import:{args[-1]}")
    def _import(self, job, jail):
        """Imports jail from zip file"""

        IOCImage().import_jail(jail)

        return True

    @private
    def get_plugin_version(self, pkg):
        """
        Fetches a list of pkg's from the http://pkg.cdn.trueos.org/iocage/
        repo and returns a list with the pkg version and plugin revision
        """
        try:
            pkg_dict = self.middleware.call_sync('cache.get',
                                                 'iocage_rpkgdict')
            r_plugins = self.middleware.call_sync('cache.get',
                                                  'iocage_rplugins')
        except KeyError:
            branch = self.get_version()
            r_pkgs = requests.get(
                f'http://pkg.cdn.trueos.org/iocage/{branch}/All')
            r_pkgs.raise_for_status()
            pkg_dict = {}
            for i in r_pkgs.iter_lines():
                i = i.decode().split('"')

                try:
                    pkg, version = i[1].rsplit('-', 1)
                    pkg_dict[pkg] = version
                except (ValueError, IndexError):
                    continue  # It's not a pkg
            self.middleware.call_sync('cache.put', 'iocage_rpkgdict', pkg_dict,
                                      86400)

            r_plugins = requests.get(
                'https://raw.githubusercontent.com/freenas/'
                f'iocage-ix-plugins/{branch}/INDEX')
            r_plugins.raise_for_status()

            r_plugins = r_plugins.json()
            self.middleware.call_sync('cache.put', 'iocage_rplugins',
                                      r_plugins, 86400)

        if pkg == 'bru-server':
            return ['N/A', '1']
        elif pkg == 'sickrage':
            return ['Git branch - master', '1']
        elif pkg == 'asigra':
            return ['14.1-20190301', '1']

        try:
            primary_pkg = r_plugins[pkg]['primary_pkg'].split('/', 1)[-1]

            version = pkg_dict[primary_pkg]
            version = [version.rsplit('%2', 1)[0].replace('.txz', ''), '1']
        except KeyError:
            version = ['N/A', 'N/A']

        return version

    @private
    def get_local_plugin_version(self, plugin, index_json, iocroot):
        """
        Checks the primary_pkg key in the INDEX with the pkg version
        inside the jail.
        """
        if index_json is None:
            return ['N/A', 'N/A']

        try:
            base_plugin = plugin.rsplit('_', 1)[0]  # May have multiple
            primary_pkg = index_json[base_plugin]['primary_pkg']
            version = ['N/A', 'N/A']

            # Since these are plugins, we don't want to spin them up just to
            # check a pkg, directly accessing the db is best in this case.
            db_rows = self.read_plugin_pkg_db(
                f'{iocroot}/jails/{plugin}/root/var/db/pkg/local.sqlite',
                primary_pkg)

            for row in db_rows:
                if primary_pkg == row[1] or primary_pkg == row[2]:
                    version = [row[3], '1']
                    break
        except (KeyError, sqlite3.OperationalError):
            version = ['N/A', 'N/A']

        return version

    @private
    def read_plugin_pkg_db(self, db, pkg):
        try:
            conn = sqlite3.connect(db)
        except sqlite3.Error as e:
            raise CallError(e)

        with conn:
            cur = conn.cursor()
            cur.execute(
                f'SELECT * FROM packages WHERE origin="{pkg}" OR name="{pkg}"')

            rows = cur.fetchall()

            return rows

    @private
    def start_on_boot(self):
        self.logger.debug('Starting jails on boot: PENDING')
        ioc.IOCage(rc=True).start()
        self.logger.debug('Starting jails on boot: SUCCESS')

        return True

    @private
    def stop_on_shutdown(self):
        self.logger.debug('Stopping jails on shutdown: PENDING')
        ioc.IOCage(rc=True).stop()
        self.logger.debug('Stopping jails on shutdown: SUCCESS')

        return True

    @private
    async def terminate(self):
        await SHUTDOWN_LOCK.acquire()

    @private
    def get_version(self):
        """
        Uses system.version and parses it out for the RELEASE branch we need
        """
        r = os.uname().release
        version = f'{round(float(r.split("-")[0]), 1)}-RELEASE'

        return version
示例#9
0
class DatastoreService(Service):
    def _filters_to_queryset(self, filters, field_suffix=None):
        opmap = {
            '=': 'exact',
            '!=': 'exact',
            '>': 'gt',
            '>=': 'gte',
            '<': 'lt',
            '<=': 'lte',
            '~': 'regex',
        }

        rv = []
        for f in filters:
            if not isinstance(f, (list, tuple)):
                raise ValueError('Filter must be a list: {0}'.format(f))
            if len(f) == 3:
                name, op, value = f
                if field_suffix:
                    name = field_suffix + name
                if op not in opmap:
                    raise Exception("Invalid operation: {0}".format(op))
                q = Q(**{'{0}__{1}'.format(name, opmap[op]): value})
                if op == '!=':
                    q.negate()
                rv.append(q)
            elif len(f) == 2:
                op, value = f
                if op == 'OR':
                    or_value = None
                    for value in self._filters_to_queryset(
                            value, field_suffix=field_suffix):
                        if or_value is None:
                            or_value = value
                        else:
                            or_value |= value
                    rv.append(or_value)
                else:
                    raise ValueError('Invalid operation: {0}'.format(op))
            else:
                raise Exception("Invalid filter {0}".format(f))
        return rv

    def __get_model(self, name):
        """Helper method to get Model for given name
        e.g. network.interfaces -> Interfaces
        """
        app, model = name.split('.', 1)
        return apps.get_model(app, model)

    def __queryset_serialize(self, qs, extend=None, field_suffix=None):
        for i in qs:
            yield django_modelobj_serialize(self.middleware,
                                            i,
                                            extend=extend,
                                            field_suffix=field_suffix)

    @accepts(
        Str('name'),
        List('query-filters', register=True),
        Dict(
            'query-options',
            Str('extend'),
            Dict('extra', additional_attrs=True),
            List('order_by'),
            Bool('count'),
            Bool('get'),
            Str('suffix'),
            register=True,
        ),
    )
    def query(self, name, filters=None, options=None):
        """Query for items in a given collection `name`.

        `filters` is a list which each entry can be in one of the following formats:

            entry: simple_filter | conjuntion
            simple_filter: '[' attribute_name, OPERATOR, value ']'
            conjunction: '[' CONJUNTION, '[' simple_filter (',' simple_filter)* ']]'

            OPERATOR: ('=' | '!=' | '>' | '>=' | '<' | '<=' | '~' )
            CONJUNCTION: 'OR'

        e.g.

        `['OR', [ ['username', '=', 'root' ], ['uid', '=', 0] ] ]`

        `[ ['username', '=', 'root' ] ]`

        .. examples(websocket)::

          Querying for username "root" and returning a single item:

            :::javascript
            {
              "id": "d51da71b-bb48-4b8b-a8f7-6046fcc892b4",
              "msg": "method",
              "method": "datastore.query",
              "params": ["account.bsdusers", [ ["username", "=", "root" ] ], {"get": true}]
            }
        """
        model = self.__get_model(name)
        if options is None:
            options = {}
        else:
            # We do not want to make changes to original options
            # which might happen with "suffix"
            options = options.copy()

        qs = model.objects.all()

        extra = options.get('extra')
        if extra:
            qs = qs.extra(**extra)

        suffix = options.get('suffix')

        if filters:
            qs = qs.filter(*self._filters_to_queryset(filters, suffix))

        order_by = options.get('order_by')
        if order_by:
            if suffix:
                # Do not change original order_by
                order_by = order_by[:]
                for i, order in enumerate(order_by):
                    if order.startswith('-'):
                        order_by[i] = '-' + suffix + order[1:]
                    else:
                        order_by[i] = suffix + order
            qs = qs.order_by(*order_by)

        if options.get('count') is True:
            return qs.count()

        result = list(
            self.__queryset_serialize(qs,
                                      extend=options.get('extend'),
                                      field_suffix=options.get('suffix')))

        if options.get('get') is True:
            return result[0]
        return result

    @accepts(Str('name'), Ref('query-options'))
    def config(self, name, options=None):
        """
        Get configuration settings object for a given `name`.

        This is a shortcut for `query(name, {"get": true})`.
        """
        if options is None:
            options = {}
        options['get'] = True
        return self.query(name, None, options)

    @accepts(Str('name'), Dict('data', additional_attrs=True))
    def insert(self, name, data):
        """
        Insert a new entry to `name`.
        """
        model = self.__get_model(name)
        for field in model._meta.fields:
            if field.name not in data:
                continue
            if isinstance(field, ForeignKey):
                data[field.name] = field.rel.to.objects.get(
                    pk=data[field.name])
        obj = model(**data)
        obj.save()
        return obj.pk

    @accepts(Str('name'), Int('id'), Dict('data', additional_attrs=True))
    def update(self, name, id, data):
        """
        Update an entry `id` in `name`.
        """
        model = self.__get_model(name)
        obj = model.objects.get(pk=id)
        for field in model._meta.fields:
            if field.name not in data:
                continue
            if isinstance(field, ForeignKey):
                data[field.name] = field.rel.to.objects.get(
                    pk=data[field.name])
        for k, v in data.items():
            setattr(obj, k, v)
        obj.save()
        return obj.pk

    @accepts(Str('name'), Int('id'))
    def delete(self, name, id):
        """
        Delete an entry `id` in `name`.
        """
        model = self.__get_model(name)
        model.objects.get(pk=id).delete()
        return True

    @private
    def sql(self, query, params=None):
        cursor = connection.cursor()
        rv = None
        try:
            if params is None:
                cursor.executelocal(query)
            else:
                cursor.executelocal(query, params)
            rv = cursor.fetchall()
        finally:
            cursor.close()
        return rv
示例#10
0
class VMDeviceService(CRUDService):

    DEVICE_ATTRS = {
        'CDROM': CDROM.schema,
        'RAW': RAW.schema,
        'DISK': DISK.schema,
        'NIC': NIC.schema,
        'PCI': PCI.schema,
        'DISPLAY': DISPLAY.schema,
    }

    class Config:
        namespace = 'vm.device'
        datastore = 'vm.device'
        datastore_extend = 'vm.device.extend_device'
        cli_namespace = 'service.vm.device'

    @private
    async def create_resource(self, device, old=None):
        return ((device['dtype'] == 'DISK'
                 and device['attributes'].get('create_zvol'))
                or (device['dtype'] == 'RAW' and
                    (not device['attributes'].get('exists', True) or
                     (old and old['attributes'].get('size') !=
                      device['attributes'].get('size')))))

    @private
    async def extend_device(self, device):
        if device['vm']:
            device['vm'] = device['vm']['id']
        if not device['order']:
            if device['dtype'] == 'CDROM':
                device['order'] = 1000
            elif device['dtype'] in ('DISK', 'RAW'):
                device['order'] = 1001
            else:
                device['order'] = 1002
        return device

    @accepts()
    def nic_attach_choices(self):
        """
        Available choices for NIC Attach attribute.
        """
        return self.middleware.call_sync('interface.choices',
                                         {'exclude': ['epair', 'tap', 'vnet']})

    @accepts()
    async def bind_choices(self):
        """
        Available choices for Bind attribute.
        """
        return {
            d['address']: d['address']
            for d in await self.middleware.call('interface.ip_in_use', {
                'static': True,
                'any': True
            })
        }

    @private
    async def update_device(self, data, old=None):
        if data['dtype'] == 'DISK':
            create_zvol = data['attributes'].pop('create_zvol', False)

            if create_zvol:
                ds_options = {
                    'name': data['attributes'].pop('zvol_name'),
                    'type': 'VOLUME',
                    'volsize': data['attributes'].pop('zvol_volsize'),
                }

                self.logger.debug(
                    f'Creating ZVOL {ds_options["name"]} with volsize {ds_options["volsize"]}'
                )

                zvol_blocksize = await self.middleware.call(
                    'pool.dataset.recommended_zvol_blocksize',
                    ds_options['name'].split('/', 1)[0])
                ds_options['volblocksize'] = zvol_blocksize

                new_zvol = (await self.middleware.call('pool.dataset.create',
                                                       ds_options))['id']
                data['attributes']['path'] = f'/dev/zvol/{new_zvol}'
        elif data['dtype'] == 'RAW' and (
                not data['attributes'].pop('exists', True) or
            (old and old['attributes']['size'] != data['attributes']['size'])):
            path = data['attributes']['path']
            cp = await run(
                ['truncate', '-s',
                 str(data['attributes']['size']), path],
                check=False)
            if cp.returncode:
                raise CallError(
                    f'Failed to create or update raw file {path}: {cp.stderr}')

        return data

    @accepts(
        Dict(
            'vmdevice_create',
            Str('dtype',
                enum=['NIC', 'DISK', 'CDROM', 'PCI', 'DISPLAY', 'RAW'],
                required=True),
            Int('vm', required=True),
            Dict('attributes', additional_attrs=True, default=None),
            Int('order', default=None, null=True),
            register=True,
        ), )
    async def do_create(self, data):
        """
        Create a new device for the VM of id `vm`.

        If `dtype` is the `RAW` type and a new raw file is to be created, `attributes.exists` will be passed as false.
        This means the API handles creating the raw file and raises the appropriate exception if file creation fails.

        If `dtype` is of `DISK` type and a new Zvol is to be created, `attributes.create_zvol` will be passed as
        true with valid `attributes.zvol_name` and `attributes.zvol_volsize` values.
        """
        data = await self.validate_device(data, update=False)
        data = await self.update_device(data)

        id = await self.middleware.call('datastore.insert',
                                        self._config.datastore, data)
        await self.__reorder_devices(id, data['vm'], data['order'])

        return await self.get_instance(id)

    @accepts(Int('id'),
             Patch(
                 'vmdevice_create',
                 'vmdevice_update',
                 ('attr', {
                     'update': True
                 }),
             ))
    async def do_update(self, id, data):
        """
        Update a VM device of `id`.

        Pass `attributes.size` to resize a `dtype` `RAW` device. The raw file will be resized.
        """
        device = await self.get_instance(id)
        new = device.copy()
        new.update(data)

        new = await self.validate_device(new, device)
        new = await self.update_device(new, device)

        await self.middleware.call('datastore.update', self._config.datastore,
                                   id, new)
        await self.__reorder_devices(id, device['vm'], new['order'])
        if device['dtype'] == 'PCI' and (new['dtype'] != 'PCI'
                                         or new['attributes']['pptdev'] !=
                                         device['attributes']['pptdev']):
            await self.middleware.call('alert.oneshot_delete',
                                       'PCIDeviceUnavailable',
                                       device['attributes']['pptdev'])

        return await self.get_instance(id)

    @private
    async def delete_resource(self, options, device):
        if options['zvol']:
            if device['dtype'] != 'DISK':
                raise CallError(
                    'The device is not a disk and has no zvol to destroy.')
            zvol_id = device['attributes'].get('path',
                                               '').rsplit('/dev/zvol/')[-1]
            if await self.middleware.call('pool.dataset.query',
                                          [['id', '=', zvol_id]]):
                # FIXME: We should use pool.dataset.delete but right now FS attachments will consider
                # the current device as a valid reference. Also should we stopping the vm only when deleting an
                # attachment ?
                await self.middleware.call('zfs.dataset.delete', zvol_id)
        if options['raw_file']:
            if device['dtype'] != 'RAW':
                raise CallError('Device is not of RAW type.')
            try:
                os.unlink(device['attributes']['path'])
            except OSError:
                raise CallError(
                    f'Failed to destroy {device["attributes"]["path"]}')

    @accepts(Int('id'),
             Dict(
                 'vm_device_delete',
                 Bool('zvol', default=False),
                 Bool('raw_file', default=False),
                 Bool('force', default=False),
             ))
    async def do_delete(self, id, options):
        """
        Delete a VM device of `id`.
        """
        device = await self.get_instance(id)
        status = await self.middleware.call('vm.status', device['vm'])
        if status.get('state') == 'RUNNING':
            raise CallError(
                'Please stop associated VM before deleting VM device.')

        try:
            await self.delete_resource(options, device)
        except CallError:
            if not options['force']:
                raise

        if device['dtype'] == 'PCI':
            await self.middleware.call('alert.oneshot_delete',
                                       'PCIDeviceUnavailable',
                                       device['attributes']['pptdev'])
            device_obj = PCI(device, middleware=self.middleware)
            if await self.middleware.run_in_thread(device_obj.safe_to_reattach
                                                   ):
                try:
                    await self.middleware.run_in_thread(
                        device_obj.reattach_device)
                except CallError:
                    if not options['force']:
                        raise

        return await self.middleware.call('datastore.delete',
                                          self._config.datastore, id)

    async def __reorder_devices(self, id, vm_id, order):
        if order is None:
            return
        filters = [('vm', '=', vm_id), ('id', '!=', id)]
        if await self.middleware.call('vm.device.query',
                                      filters + [('order', '=', order)]):
            used_order = [order]
            for device in await self.middleware.call('vm.device.query',
                                                     filters,
                                                     {'order_by': ['order']}):
                if not device['order']:
                    continue

                if device['order'] not in used_order:
                    used_order.append(device['order'])
                    continue

                device['order'] = min(used_order) + 1
                while device['order'] in used_order:
                    device['order'] += 1
                used_order.append(device['order'])
                await self.middleware.call('datastore.update',
                                           self._config.datastore,
                                           device['id'], device)

    @private
    async def disk_uniqueness_integrity_check(self, device, vm):
        # This ensures that the disk is not already present for `vm`
        def translate_device(dev):
            # A disk should have a path configured at all times, when that is not the case, that means `dtype` is DISK
            # and end user wants to create a new zvol in this case.
            return dev['attributes'].get(
                'path') or f'/dev/zvol/{dev["attributes"]["zvol_name"]}'

        disks = [
            d for d in vm['devices'] if d['dtype'] in ('DISK', 'RAW', 'CDROM')
            and translate_device(d) == translate_device(device)
        ]
        if not disks:
            # We don't have that disk path in vm devices, we are good to go
            return True
        elif len(disks) > 1:
            # VM is mis-configured
            return False
        elif not device.get('id') and disks:
            # A new device is being created, however it already exists in vm. This can also happen when VM instance
            # is being created, in that case it's okay. Key here is that we won't have the id field present
            return not bool(disks[0].get('id'))
        elif device.get('id'):
            # The device is being updated, if the device is same as we have in db, we are okay
            return device['id'] == disks[0].get('id')
        else:
            return False

    @private
    async def validate_device(self,
                              device,
                              old=None,
                              vm_instance=None,
                              update=True):
        # We allow vm_instance to be passed for cases where VM devices are being updated via VM and
        # the device checks should be performed with the modified vm_instance object not the one db holds
        # vm_instance should be provided at all times when handled by VMService, if VMDeviceService is interacting,
        # then it means the device is configured with a VM and we can retrieve the VM's data from db
        if not vm_instance:
            vm_instance = await self.middleware.call('vm.get_instance',
                                                     device['vm'])

        verrors = ValidationErrors()
        schema = self.DEVICE_ATTRS.get(device['dtype'])
        if schema:
            try:
                device['attributes'] = schema.clean(device['attributes'])
            except Error as e:
                verrors.add(f'attributes.{e.attribute}', e.errmsg, e.errno)

            try:
                schema.validate(device['attributes'])
            except ValidationErrors as e:
                verrors.extend(e)

            if verrors:
                raise verrors

        # vm_instance usages SHOULD NOT rely on device `id` field to uniquely identify objects as it's possible
        # VMService is creating a new VM with devices and the id's don't exist yet

        if device.get('dtype') == 'DISK':
            create_zvol = device['attributes'].get('create_zvol')
            path = device['attributes'].get('path')
            if create_zvol:
                for attr in ('zvol_name', 'zvol_volsize'):
                    if not device['attributes'].get(attr):
                        verrors.add(f'attributes.{attr}',
                                    'This field is required.')
                parentzvol = (device['attributes'].get('zvol_name')
                              or '').rsplit('/', 1)[0]
                if parentzvol and not await self.middleware.call(
                        'pool.dataset.query', [('id', '=', parentzvol)]):
                    verrors.add(
                        'attributes.zvol_name',
                        f'Parent dataset {parentzvol} does not exist.',
                        errno.ENOENT)
                zvol = await self.middleware.call(
                    'pool.dataset.query',
                    [['id', '=', device['attributes'].get('zvol_name')]])
                if not verrors and create_zvol and zvol:
                    verrors.add(
                        'attributes.zvol_name',
                        f'{device["attributes"]["zvol_name"]} already exists.')
                elif zvol and zvol[0]['locked']:
                    verrors.add('attributes.zvol_name',
                                f'{zvol[0]["id"]} is locked.')
            elif not path:
                verrors.add('attributes.path', 'Disk path is required.')
            elif path and not os.path.exists(path):
                verrors.add('attributes.path',
                            f'Disk path {path} does not exist.', errno.ENOENT)

            if path and len(path) > 63:
                # SPECNAMELEN is not long enough (63) in 12, 13 will be 255
                verrors.add(
                    'attributes.path',
                    f'Disk path {path} is too long, reduce to less than 63 characters',
                    errno.ENAMETOOLONG)
            if not await self.disk_uniqueness_integrity_check(
                    device, vm_instance):
                verrors.add(
                    'attributes.path',
                    f'{vm_instance["name"]} has "{path}" already configured')
        elif device.get('dtype') == 'RAW':
            path = device['attributes'].get('path')
            exists = device['attributes'].get('exists', True)
            if not path:
                verrors.add('attributes.path', 'Path is required.')
            else:
                if exists and not os.path.exists(path):
                    verrors.add('attributes.path', 'Path must exist.')
                if not exists:
                    if os.path.exists(path):
                        verrors.add('attributes.path', 'Path must not exist.')
                    elif not device['attributes'].get('size'):
                        verrors.add(
                            'attributes.size',
                            'Please provide a valid size for the raw file.')
                if (old and old['attributes'].get('size') !=
                        device['attributes'].get('size')
                        and not device['attributes'].get('size')):
                    verrors.add(
                        'attributes.size',
                        'Please provide a valid size for the raw file.')
                await check_path_resides_within_volume(
                    verrors,
                    self.middleware,
                    'attributes.path',
                    path,
                )
                if not await self.disk_uniqueness_integrity_check(
                        device, vm_instance):
                    verrors.add(
                        'attributes.path',
                        f'{vm_instance["name"]} has "{path}" already configured'
                    )
        elif device.get('dtype') == 'CDROM':
            path = device['attributes'].get('path')
            if not path:
                verrors.add('attributes.path', 'Path is required.')
            elif not os.path.exists(path):
                verrors.add('attributes.path',
                            f'Unable to locate CDROM device at {path}')
            elif not await self.disk_uniqueness_integrity_check(
                    device, vm_instance):
                verrors.add(
                    'attributes.path',
                    f'{vm_instance["name"]} has "{path}" already configured')
        elif device.get('dtype') == 'NIC':
            nic = device['attributes'].get('nic_attach')
            if nic:
                nic_choices = await self.middleware.call(
                    'vm.device.nic_attach_choices')
                if nic not in nic_choices:
                    verrors.add('attributes.nic_attach', 'Not a valid choice.')
            await self.failover_nic_check(device, verrors, 'attributes')
        elif device.get('dtype') == 'PCI':
            pptdev = device['attributes'].get('pptdev')
            if osc.IS_FREEBSD and not RE_PPTDEV_NAME.findall(pptdev):
                verrors.add('attribute.pptdev',
                            'Please specify correct PCI device for passthru.')
            device_details = await self.middleware.call(
                'vm.device.passthrough_device', pptdev)
            if device_details.get('error'):
                verrors.add(
                    'attribute.pptdev',
                    f'Not a valid choice. The PCI device is not available for passthru: {device_details["error"]}'
                )
            if not await self.middleware.call('vm.device.iommu_enabled'):
                verrors.add('attribute.pptdev', 'IOMMU support is required.')
        elif device.get('dtype') == 'DISPLAY':
            if vm_instance:
                if osc.IS_FREEBSD and vm_instance['bootloader'] != 'UEFI':
                    verrors.add('dtype',
                                'Display only works with UEFI bootloader.')

                if not update:
                    vm_instance['devices'].append(device)

                await self.validate_display_devices(verrors, vm_instance)

            if osc.IS_FREEBSD and device['attributes']['type'] != 'VNC':
                verrors.add(
                    'attributes.type',
                    'Only VNC Display device is supported for this platform.')

            all_ports = [
                d['attributes'].get('port')
                for d in (await self.middleware.call(
                    'vm.device.query', [['dtype', '=', 'DISPLAY']]))
                if d['id'] != device.get('id')
            ]
            if device['attributes'].get('port'):
                if device['attributes']['port'] in all_ports:
                    verrors.add('attributes.port',
                                'Specified display port is already in use')
            else:
                device['attributes']['port'] = (
                    await self.middleware.call('vm.port_wizard'))['port']

        if device['dtype'] in ('RAW', 'DISK') and device['attributes'].get('physical_sectorsize')\
                and not device['attributes'].get('logical_sectorsize'):
            verrors.add(
                'attributes.logical_sectorsize',
                'This field must be provided when physical_sectorsize is specified.'
            )

        if verrors:
            raise verrors

        return device

    @private
    async def validate_display_devices(self, verrors, vm_instance):
        devs = await self.get_display_devices(vm_instance)
        if len(devs['vnc']) > 1:
            verrors.add('attributes.type',
                        'Only one VNC Display device is supported')
        if len(devs['spice']) > 1:
            verrors.add('attributes.type',
                        'Only one SPICE Display device is supported')

    @private
    async def get_display_devices(self, vm_instance):
        devs = {'vnc': [], 'spice': []}
        for dev in filter(lambda d: d['dtype'] == 'DISPLAY',
                          vm_instance['devices']):
            if dev['attributes']['type'] == 'SPICE':
                devs['spice'].append(dev)
            else:
                devs['vnc'].append(dev)
        return devs

    @private
    async def failover_nic_check(self, vm_device, verrors, schema):
        if await self.middleware.call('system.is_enterprise'
                                      ) and await self.middleware.call(
                                          'failover.licensed'):
            nics = await self.middleware.call(
                'vm.device.nic_capability_checks', [vm_device])
            if nics:
                verrors.add(
                    f'{schema}.nic_attach',
                    f'Capabilities must be disabled for {",".join(nics)} interface '
                    'in Network->Interfaces section before using this device with VM.'
                )
示例#11
0
class PeriodicSnapshotTaskService(CRUDService):
    class Config:
        datastore = 'storage.task'
        datastore_prefix = 'task_'
        datastore_extend = 'pool.snapshottask.periodic_snapshot_extend'
        namespace = 'pool.snapshottask'

    @private
    def periodic_snapshot_extend(self, data):
        data['begin'] = str(data['begin'])
        data['end'] = str(data['end'])
        data['ret_unit'] = data['ret_unit'].upper()
        data['dow'] = [int(day) for day in data.pop('byweekday').split(',')]
        data.pop('repeat_unit', None)
        return data

    @private
    async def common_validation(self, data, schema_name):
        verrors = ValidationErrors()

        if not data['dow']:
            verrors.add(f'{schema_name}.dow',
                        'At least one day must be chosen')

        data['ret_unit'] = data['ret_unit'].lower()
        data['begin'] = time(
            *[int(value) for value in data['begin'].split(':')])
        data['end'] = time(*[int(value) for value in data['end'].split(':')])
        data['byweekday'] = ','.join([str(day) for day in data.pop('dow')])

        return data, verrors

    @accepts(
        Dict('periodic_snapshot_create',
             Bool('enabled', default=True),
             Bool('recursive', default=False),
             Int('interval',
                 enum=[
                     5, 10, 15, 30, 60, 120, 180, 240, 360, 720, 1440, 10080,
                     20160, 40320
                 ],
                 required=True),
             Int('ret_count', required=True),
             List('dow',
                  items=[Int('day', validators=[Range(min=1, max=7)])],
                  required=True),
             Str('begin', validators=[Time()], required=True),
             Str('end', validators=[Time()], required=True),
             Str('filesystem', required=True),
             Str('ret_unit',
                 enum=['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'],
                 required=True),
             register=True))
    async def do_create(self, data):

        data, verrors = await self.common_validation(
            data, 'periodic_snapshot_create')

        if data['filesystem'] not in (
                await self.middleware.call('pool.filesystem_choices')):
            verrors.add('periodic_snapshot_create.filesystem',
                        'Invalid ZFS filesystem')

        if verrors:
            raise verrors

        data['id'] = await self.middleware.call(
            'datastore.insert', self._config.datastore, data,
            {'prefix': self._config.datastore_prefix})

        await self.middleware.call('service.restart', 'cron',
                                   {'onetime': False})

        return await self._get_instance(data['id'])

    @accepts(Int('id', required=True),
             Patch('periodic_snapshot_create', 'periodic_snapshot_update',
                   ('attr', {
                       'update': True
                   })))
    async def do_update(self, id, data):

        old = await self._get_instance(id)
        new = old.copy()
        new.update(data)

        new, verrors = await self.common_validation(
            new, 'periodic_snapshot_update')

        if old['filesystem'] != new['filesystem']:
            if new['filesystem'] not in (
                    await self.middleware.call('pool.filesystem_choices')):
                verrors.add('periodic_snapshot_update.filesystem',
                            'Invalid ZFS filesystem')

        if verrors:
            raise verrors

        await self.middleware.call('datastore.update', self._config.datastore,
                                   id, new,
                                   {'prefix': self._config.datastore_prefix})

        await self.middleware.call('service.restart', 'cron',
                                   {'onetime': False})

        return await self._get_instance(id)

    @accepts(Int('id'))
    async def do_delete(self, id):
        response = await self.middleware.call('datastore.delete',
                                              self._config.datastore, id)

        return response
示例#12
0
class KerberosKeytabService(CRUDService):
    class Config:
        datastore = 'directoryservice.kerberoskeytab'
        datastore_prefix = 'keytab_'
        namespace = 'kerberos.keytab'

    @accepts(
        Dict('kerberos_keytab_create',
             Str('file', max_length=None),
             Str('name'),
             register=True))
    async def do_create(self, data):
        """
        Create a kerberos keytab. Uploaded keytab files will be merged with the system
        keytab under /etc/krb5.keytab.

        `file` b64encoded kerberos keytab
        `name` name for kerberos keytab
        """
        verrors = ValidationErrors()

        verrors.add_child('kerberos_principal_create', await
                          self._validate(data))

        if verrors:
            raise verrors

        data["id"] = await self.middleware.call(
            "datastore.insert",
            self._config.datastore,
            data,
            {"prefix": self._config.datastore_prefix},
        )
        await self.middleware.call('etc.generate', 'kerberos')

        return await self._get_instance(data['id'])

    @accepts(Int('id', required=True),
             Dict('kerberos_keytab_update',
                  Str('file'),
                  Str('name'),
                  register=True))
    async def do_update(self, id, data):
        """
        Update kerberos keytab by id.
        """
        old = await self._get_instance(id)
        new = old.copy()
        new.update(data)

        verrors = ValidationErrors()

        verrors.add_child('kerberos_principal_update', await
                          self._validate(new))

        if verrors:
            raise verrors

        await self.middleware.call('datastore.update', self._config.datastore,
                                   id, new,
                                   {'prefix': self._config.datastore_prefix})
        await self.middleware.call('etc.generate', 'kerberos')

        return await self._get_instance(id)

    @accepts(Int('id'))
    async def do_delete(self, id):
        """
        Delete kerberos keytab by id, and force regeneration of
        system keytab.
        """
        await self.middleware.call("datastore.delete", self._config.datastore,
                                   id)
        if os.path.exists(keytab['SYSTEM'].value):
            os.remove(keytab['SYSTEM'].value)
        await self.middleware.call('etc.generate', 'kerberos')
        await self._cleanup_kerberos_principals()
        await self.middleware.call('kerberos.stop')
        try:
            await self.middleware.call('kerberos.start')
        except Exception as e:
            self.logger.debug(
                'Failed to start kerberos service after deleting keytab entry: %s'
                % e)

    @accepts(Dict(
        'keytab_data',
        Str('name', required=True),
    ))
    @job(lock='upload_keytab', pipes=['input'], check_pipes=True)
    async def upload_keytab(self, job, data):
        """
        Upload a keytab file. This method expects the keytab file to be uploaded using
        the /_upload/ endpoint.
        """
        ktmem = io.BytesIO()
        await self.middleware.run_in_thread(shutil.copyfileobj,
                                            job.pipes.input.r, ktmem)
        b64kt = base64.b64encode(ktmem.getvalue())
        return await self.middleware.call('kerberos.keytab.create', {
            'name': data['name'],
            'file': b64kt.decode()
        })

    @private
    async def legacy_validate(self, keytab):
        err = await self._validate({'file': keytab})
        try:
            err.check()
        except Exception as e:
            raise CallError(e)

    @private
    async def _cleanup_kerberos_principals(self):
        principal_choices = await self.middleware.call(
            'kerberos.keytab.kerberos_principal_choices')
        ad = await self.middleware.call('activedirectory.config')
        ldap = await self.middleware.call('ldap.config')
        if ad['kerberos_principal'] and ad[
                'kerberos_principal'] not in principal_choices:
            await self.middleware.call('datastore.update',
                                       'directoryservice.activedirectory',
                                       ad['id'], {'kerberos_principal': ''},
                                       {'prefix': 'ad_'})
        if ldap['kerberos_principal'] and ldap[
                'kerberos_principal'] not in principal_choices:
            await self.middleware.call('datastore.update',
                                       'directoryservice.ldap', ldap['id'],
                                       {'kerberos_principal': ''},
                                       {'prefix': 'ldap_'})

    @private
    async def do_ktutil_list(self, data):
        kt = data.get("kt_name", keytab.SYSTEM.value)
        if KRB5.platform() == KRB5.MIT:
            ktutil = await Popen(['ktutil'],
                                 stdout=subprocess.PIPE,
                                 stderr=subprocess.PIPE,
                                 stdin=subprocess.PIPE)
            output = await ktutil.communicate(f'rkt {kt}\nlist\nq\n'.encode())
            if output[1]:
                raise CallError(output[1].decode())
            ret = '\n'.join(output[0].decode().splitlines()[4:-1])
        else:
            ktutil = await run(["ktutil", "-k", kt, "-v", "list"], check=False)
            if ktutil.returncode != 0:
                raise CallError(ktutil.stderr.decode())
            ret = ktutil.stdout.decode()

        return ret

    @private
    async def _validate(self, data):
        """
        For now validation is limited to checking if we can resolve the hostnames
        configured for the kdc, admin_server, and kpasswd_server can be resolved
        by DNS, and if the realm can be resolved by DNS.
        """
        verrors = ValidationErrors()
        try:
            decoded = base64.b64decode(data['file'])
        except Exception as e:
            verrors.add(
                "kerberos.keytab_create",
                f"Keytab is a not a properly base64-encoded string: [{e}]")
            return verrors

        with open(keytab['TEST'].value, "wb") as f:
            f.write(decoded)

        try:
            await self.do_ktutil_list({"kt_name": keytab['TEST'].value})
        except CallError as e:
            verrors.add("kerberos.keytab_create",
                        f"Failed to validate keytab: [{e.errmsg}]")

        os.unlink(keytab['TEST'].value)

        return verrors

    @private
    async def _ktutil_list(self, keytab_file=keytab['SYSTEM'].value):
        keytab_entries = []
        try:
            kt_list_output = await self.do_ktutil_list(
                {"kt_name": keytab_file})
        except Exception as e:
            self.logger.warning("Failed to list kerberos keytab [%s]: %s",
                                keytab_file, e)
            kt_list_output = None

        if not kt_list_output:
            return keytab_entries

        if KRB5.platform() == KRB5.HEIMDAL:
            for line in kt_list_output.splitlines():
                fields = line.split()
                if len(fields) >= 4 and fields[0] != 'Vno':
                    if fields[1] == 'unknown':
                        self.logger.warning(
                            'excluding unknown encryption type %s from keytab choices',
                            fields[2])
                        continue

                    keytab_entries.append({
                        'kvno':
                        fields[0],
                        'type':
                        fields[1],
                        'principal':
                        fields[2],
                        'date':
                        time.strptime(fields[3], '%Y-%m-%d'),
                        'aliases':
                        fields[4].split() if len(fields) == 5 else []
                    })
        else:
            for line in kt_list_output.splitlines():
                fields = line.split()
                keytab_entries.append({
                    'slot': fields[0],
                    'kvno': fields[1],
                    'principal': fields[2],
                })

        return keytab_entries

    @accepts()
    async def system_keytab_list(self):
        """
        Returns content of system keytab (/etc/krb5.keytab).
        """
        kt_list = await self._ktutil_list()
        parsed = []
        for entry in kt_list:
            entry['date'] = time.mktime(entry['date'])
            parsed.append(entry)

        return parsed

    @private
    async def _get_nonsamba_principals(self, keytab_list):
        """
        Generate list of Kerberos principals that are not the AD machine account.
        """
        ad = await self.middleware.call('activedirectory.config')
        pruned_list = []
        for i in keytab_list:
            if ad['netbiosname'].upper() not in i['principal'].upper():
                pruned_list.append(i)

        return pruned_list

    @private
    async def _generate_tmp_keytab(self):
        """
        Generate a temporary keytab to separate out the machine account keytab principal.
        ktutil copy returns 1 even if copy succeeds.
        """
        if os.path.exists(keytab['SAMBA'].value):
            os.remove(keytab['SAMBA'].value)

        if KRB5.platform() == KRB5.HEIMDAL:
            kt_copy = await run([
                'ktutil', 'copy', keytab['SYSTEM'].value, keytab['SAMBA'].value
            ],
                                check=False)
            if kt_copy.stderr.decode():
                raise CallError(
                    f"failed to generate [{keytab['SAMBA'].value}]: {kt_copy.stderr.decode()}"
                )
        else:
            kt_copy = await Popen(['ktutil'],
                                  stdout=subprocess.PIPE,
                                  stderr=subprocess.PIPE,
                                  stdin=subprocess.PIPE)
            output = await kt_copy.communicate(
                f'rkt {keytab.SYSTEM.value}\nwkt {keytab.SAMBA.value}'.encode(
                ))
            if output[1]:
                raise CallError(
                    f"failed to generate [{keytab['SAMBA'].value}]: {output[1].decode()}"
                )

    @private
    async def _prune_keytab_principals(self, to_delete=[]):
        """
        Delete all keytab entries from the tmp keytab that are not samba entries.
        """
        seen_principals = []
        if KRB5.platform() == KRB5.HEIMDAL:
            for i in to_delete:
                if i['principal'] in seen_principals:
                    continue
                ktutil_remove = await run([
                    'ktutil', '-k', keytab['SAMBA'].value, 'remove', '-p',
                    i['principal']
                ],
                                          check=False)
                if ktutil_remove.stderr.decode():
                    raise CallError(
                        f"ktutil_remove failed for [{i}]: {ktutil_remove.stderr.decode()}"
                    )
                seen_principals.append(i['principal'])

        else:
            rkt = f"rkt {keytab.SAMBA.value}"
            wkt = f"wkt {keytab.SAMBA.value}"
            for i in reversed(to_delete):
                ktutil_remove = await Popen(['ktutil'],
                                            stdout=subprocess.PIPE,
                                            stderr=subprocess.PIPE,
                                            stdin=subprocess.PIPE)
                output = await ktutil_remove.communicate(
                    f'{rkt}\ndelent {i["slot"]}\n{wkt}')
                if output[1]:
                    raise CallError(output[1].decode())

    @private
    async def kerberos_principal_choices(self):
        """
        Keytabs typically have multiple entries for same principal (differentiated by enc_type).
        Since the enctype isn't relevant in this situation, only show unique principal names.
        _ktutil_list() will raise exception if system keytab doesn't exist. In this case, return
        empty list.
        """
        try:
            keytab_list = await self._ktutil_list()
        except Exception as e:
            self.logger.trace(
                '"ktutil list" failed. Generating empty list of kerberos principal choices. Error: %s'
                % e)
            return []

        kerberos_principals = []
        for entry in keytab_list:
            if entry['principal'] not in kerberos_principals:
                kerberos_principals.append(entry['principal'])

        return sorted(kerberos_principals)

    @private
    async def store_samba_keytab(self):
        """
        Samba will automatically generate system keytab entries for the AD machine account
        (netbios name with '$' appended), and maintain them through machine account password changes.

        Copy the system keytab, parse it, and update the corresponding keytab entry in the freenas configuration
        database.

        The current system kerberos keytab and compare with a cached copy before overwriting it when a new
        keytab is generated through middleware 'etc.generate kerberos'.
        """
        if not os.path.exists(keytab['SYSTEM'].value):
            return False

        encoded_keytab = None
        keytab_list = await self._ktutil_list()
        items_to_remove = await self._get_nonsamba_principals(keytab_list)
        await self._generate_tmp_keytab()
        await self._prune_keytab_principals(items_to_remove)
        with open(keytab['SAMBA'].value, 'rb') as f:
            encoded_keytab = base64.b64encode(f.read())

        if not encoded_keytab:
            self.logger.debug(
                f"Failed to generate b64encoded version of {keytab['SAMBA'].name}"
            )
            return False

        keytab_file = encoded_keytab.decode()
        entry = await self.query([('name', '=', 'AD_MACHINE_ACCOUNT')])
        if not entry:
            await self.middleware.call('datastore.insert',
                                       'directoryservice.kerberoskeytab', {
                                           'keytab_name': 'AD_MACHINE_ACCOUNT',
                                           'keytab_file': keytab_file
                                       })
        else:
            id = entry[0]['id']
            updated_entry = {
                'keytab_name': 'AD_MACHINE_ACCOUNT',
                'keytab_file': keytab_file
            }
            await self.middleware.call('datastore.update',
                                       'directoryservice.kerberoskeytab', id,
                                       updated_entry)

        sambakt = await self.query([('name', '=', 'AD_MACHINE_ACCOUNT')])
        if sambakt:
            return sambakt[0]['id']

    @periodic(3600)
    @private
    async def check_updated_keytab(self):
        """
        Check mtime of current kerberos keytab. If it has changed since last check,
        assume that samba has updated it behind the scenes and that the configuration
        database needs to be updated to reflect the change.
        """
        old_mtime = 0
        ad_state = await self.middleware.call('activedirectory.get_state')
        if ad_state == 'DISABLED' or not os.path.exists(
                keytab['SYSTEM'].value):
            return

        if (await self.middleware.call("smb.get_smb_ha_mode")) == "LEGACY":
            return

        if await self.middleware.call('cache.has_key', 'KEYTAB_MTIME'):
            old_mtime = await self.middleware.call('cache.get', 'KEYTAB_MTIME')

        new_mtime = (os.stat(keytab['SYSTEM'].value)).st_mtime
        if old_mtime == new_mtime:
            return

        ts = await self.middleware.call(
            'directoryservices.get_last_password_change')
        if ts['dbconfig'] == ts['secrets']:
            return

        self.logger.debug(
            "Machine account password has changed. Stored copies of "
            "kerberos keytab and directory services secrets will now "
            "be updated.")

        await self.middleware.call('directoryservices.backup_secrets')
        await self.store_samba_keytab()
        self.logger.trace('Updating stored AD machine account kerberos keytab')
        await self.middleware.call('cache.put', 'KEYTAB_MTIME',
                                   (os.stat(keytab['SYSTEM'].value)).st_mtime)
示例#13
0
class KerberosRealmService(CRUDService):
    class Config:
        datastore = 'directoryservice.kerberosrealm'
        datastore_prefix = 'krb_'
        datastore_extend = 'kerberos.realm.kerberos_extend'
        namespace = 'kerberos.realm'

    @private
    async def kerberos_extend(self, data):
        for param in ['kdc', 'admin_server', 'kpasswd_server']:
            data[param] = data[param].split(' ') if data[param] else []

        return data

    @private
    async def kerberos_compress(self, data):
        for param in ['kdc', 'admin_server', 'kpasswd_server']:
            data[param] = ' '.join(data[param])

        return data

    @accepts(
        Dict('kerberos_realm_create',
             Str('realm', required=True),
             List('kdc', default=[]),
             List('admin_server', default=[]),
             List('kpasswd_server', default=[]),
             register=True))
    async def do_create(self, data):
        """
        Create a new kerberos realm. This will be automatically populated during the
        domain join process in an Active Directory environment. Kerberos realm names
        are case-sensitive, but convention is to only use upper-case.

        Entries for kdc, admin_server, and kpasswd_server are not required.
        If they are unpopulated, then kerberos will use DNS srv records to
        discover the correct servers. The option to hard-code them is provided
        due to AD site discovery. Kerberos has no concept of Active Directory
        sites. This means that middleware performs the site discovery and
        sets the kerberos configuration based on the AD site.
        """
        verrors = ValidationErrors()

        verrors.add_child('kerberos_realm_create', await self._validate(data))

        if verrors:
            raise verrors

        data = await self.kerberos_compress(data)
        data["id"] = await self.middleware.call(
            "datastore.insert",
            self._config.datastore,
            data,
            {"prefix": self._config.datastore_prefix},
        )
        await self.middleware.call('etc.generate', 'kerberos')
        await self.middleware.call('service.restart', 'cron')
        return await self._get_instance(data['id'])

    @accepts(Int('id', required=True),
             Patch("kerberos_realm_create", "kerberos_realm_update",
                   ("attr", {
                       "update": True
                   })))
    async def do_update(self, id, data):
        """
        Update a kerberos realm by id. This will be automatically populated during the
        domain join process in an Active Directory environment. Kerberos realm names
        are case-sensitive, but convention is to only use upper-case.
        """
        old = await self._get_instance(id)
        new = old.copy()
        new.update(data)

        data = await self.kerberos_compress(new)
        await self.middleware.call('datastore.update', self._config.datastore,
                                   id, new,
                                   {'prefix': self._config.datastore_prefix})

        await self.middleware.call('etc.generate', 'kerberos')
        return await self._get_instance(id)

    @accepts(Int('id'))
    async def do_delete(self, id):
        """
        Delete a kerberos realm by ID.
        """
        await self.middleware.call("datastore.delete", self._config.datastore,
                                   id)
        await self.middleware.call('etc.generate', 'kerberos')

    @private
    async def _validate(self, data):
        verrors = ValidationErrors()
        realms = await self.query()
        for realm in realms:
            if realm['realm'].upper() == data['realm'].upper():
                verrors.add(
                    f'kerberos_realm',
                    f'kerberos realm with name {realm["realm"]} already exists.'
                )
        return verrors
示例#14
0
class KerberosService(ConfigService):
    class Config:
        service = "kerberos"
        datastore = 'directoryservice.kerberossettings'
        datastore_prefix = "ks_"

    @accepts(
        Dict('kerberos_settings_update',
             Str('appdefaults_aux', max_length=None),
             Str('libdefaults_aux', max_length=None),
             update=True))
    async def do_update(self, data):
        """
        `appdefaults_aux` add parameters to "appdefaults" section of the krb5.conf file.

        `libdefaults_aux` add parameters to "libdefaults" section of the krb5.conf file.
        """
        verrors = ValidationErrors()

        old = await self.config()
        new = old.copy()
        new.update(data)
        verrors.add_child(
            'kerberos_settings_update', await
            self._validate_appdefaults(new['appdefaults_aux']))
        verrors.add_child(
            'kerberos_settings_update', await
            self._validate_libdefaults(new['libdefaults_aux']))
        if verrors:
            raise verrors

        await self.middleware.call('datastore.update', self._config.datastore,
                                   old['id'], new, {'prefix': 'ks_'})

        await self.middleware.call('etc.generate', 'kerberos')
        return await self.config()

    @private
    async def _klist_test(self):
        """
        Returns false if there is not a TGT or if the TGT has expired.
        """
        if KRB5.platform() == KRB5.MIT:
            klist = await run(['klist', '-s'], check=False)
        else:
            klist = await run(['klist', '-t'], check=False)

        if klist.returncode != 0:
            return False
        return True

    @private
    async def _validate_param_type(self, data):
        supported_validation_types = [
            'boolean',
            'cctype',
            'etypes',
            'keytab',
        ]
        if data['ptype'] not in supported_validation_types:
            return

        if data['ptype'] == 'boolean':
            if data['value'].upper() not in ['YES', 'TRUE', 'NO', 'FALSE']:
                raise CallError(f'[{data["value"]}] is not boolean')

        if data['ptype'] == 'etypes':
            for e in data['value'].split(' '):
                try:
                    KRB_ETYPE(e)
                except Exception:
                    raise CallError(
                        f'[{e}] is not a supported encryption type')

        if data['ptype'] == 'cctype':
            for cctype in ['DIR', 'FILE', 'MEMORY']:
                if cctype not in data['value']:
                    raise CallError(
                        f'{data["value"]} is an unsupported cctype')

        if data['ptype'] == 'keytab':
            try:
                keytab(data['value'])
            except Exception:
                raise CallError(
                    f'{data["value"]} is an unsupported keytab path')

    @private
    async def _validate_appdefaults(self, appdefaults):
        verrors = ValidationErrors()
        for line in appdefaults.splitlines():
            param = line.split('=')
            if len(param) == 2 and (param[1].strip())[0] != '{':
                validated_param = list(
                    filter(lambda x: param[0].strip() in (x.value)[0],
                           KRB_AppDefaults))

                if not validated_param:
                    verrors.add(
                        'kerberos_appdefaults',
                        f'{param[0]} is an invalid appdefaults parameter.')
                    continue

                try:
                    await self._validate_param_type({
                        'ptype': (validated_param[0]).value[1],
                        'value':
                        param[1].strip()
                    })
                except Exception as e:
                    verrors.add('kerberos_appdefaults',
                                f'{param[0]} has invalid value: {e.errmsg}.')
                    continue

        return verrors

    @private
    async def _validate_libdefaults(self, libdefaults):
        verrors = ValidationErrors()
        for line in libdefaults.splitlines():
            param = line.split('=')
            if len(param) == 2:
                validated_param = list(
                    filter(lambda x: param[0].strip() in (x.value)[0],
                           KRB_LibDefaults))

                if not validated_param:
                    verrors.add(
                        'kerberos_libdefaults',
                        f'{param[0]} is an invalid libdefaults parameter.')
                    continue

                try:
                    await self._validate_param_type({
                        'ptype': (validated_param[0]).value[1],
                        'value':
                        param[1].strip()
                    })
                except Exception as e:
                    verrors.add('kerberos_libdefaults',
                                f'{param[0]} has invalid value: {e.errmsg}.')

            else:
                verrors.add('kerberos_libdefaults',
                            f'{line} is an invalid libdefaults parameter.')

        return verrors

    @private
    async def do_kinit(self, data):
        dstype = DSType(data['dstype'])
        krb5 = KRB5.platform()
        if data['kerberos_principal']:
            if krb5 == KRB5.MIT:
                kinit = await run(
                    ['kinit', '-r', '7d', '-k', data['kerberos_principal']],
                    check=False)
            else:
                kinit = await run(
                    ['kinit', '--renewable', '-k', data['kerberos_principal']],
                    check=False)

            if kinit.returncode != 0:
                if dstype == DSType.DS_TYPE_ACTIVEDIRECTORY:
                    raise CallError(
                        f"kinit for domain [{data['domainname']}] "
                        f"with principal [{data['kerberos_principal']}] "
                        f"failed: {kinit.stderr.decode()}")

                elif dstype == DSType.LDAP:
                    raise CallError(
                        f"kinit with principal [{data['kerberos_principal']}] "
                        f"failed: {kinit.stderr.decode()}")
            return True

        if dstype == DSType.DS_TYPE_ACTIVEDIRECTORY:
            principal = f'{data["bindname"]}@{data["domainname"].upper()}'

        elif dstype == DSType.LDAP:
            krb_realm = await self.middleware.call(
                'kerberos.realm.query', [('id', '=', data['kerberos_realm'])],
                {'get': True})
            bind_cn = (data['binddn'].split(','))[0].split("=")
            principal = f'{bind_cn[1]}@{krb_realm["realm"]}'

        if krb5 == KRB5.MIT:
            kinit = await Popen(['kinit', '-r', '7d', principal],
                                stdout=subprocess.PIPE,
                                stderr=subprocess.PIPE,
                                stdin=subprocess.PIPE)
        else:
            kinit = await Popen(
                ['kinit', '--renewable', '--password-file=STDIN', principal],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                stdin=subprocess.PIPE)

        output = await kinit.communicate(input=data['bindpw'].encode())
        if kinit.returncode != 0:
            raise CallError(
                f"kinit with password failed: {output[1].decode()}")

        return True

    @private
    async def _kinit(self):
        """
        For now we only check for kerberos realms explicitly configured in AD and LDAP.
        """
        data = {}
        ad = await self.middleware.call('activedirectory.config')
        ldap = await self.middleware.call('ldap.config')
        await self.middleware.call('etc.generate', 'kerberos')
        if ad['enable']:
            ad['dstype'] = DSType.DS_TYPE_ACTIVEDIRECTORY.value
            await self.do_kinit(ad)

        if ldap['enable'] and ldap['kerberos_realm']:
            ldap['dstype'] = DSType.DS_TYPE_LDAP.value
            await self.do_kinit(ldap)

    @private
    async def parse_klist(self, data):
        ad_TGT = []
        ldap_TGT = []

        ad = data.get("ad")
        ldap = data.get("ldap")
        klistin = data.get("klistin")
        krb5 = KRB5(data.get("krb5type"))

        if krb5 == KRB5.MIT:
            tickets = klistin.splitlines()
            default_principal = None
            tlen = len(tickets)

            if ad['enable']:
                dstype = DSType.DS_TYPE_ACTIVEDIRECTORY
            elif ldap['enable']:
                dstype = DSType.DS_TYPE_LDAP
            else:
                return {"ad_TGT": [], "ldap_TGT": []}

            parsed_klist = []
            for idx, e in enumerate(tickets):
                if e.startswith('Default'):
                    default_principal = (e.split(':')[1]).strip()
                if e and e[0].isdigit():
                    d = e.split("  ")
                    issued = time.strptime(d[0], "%m/%d/%y %H:%M:%S")
                    expires = time.strptime(d[1], "%m/%d/%y %H:%M:%S")
                    client = default_principal
                    server = d[2]
                    flags = None
                    etype = None
                    next_two = [idx + 1, idx + 2]
                    for i in next_two:
                        if i >= tlen:
                            break
                        if tickets[i][0].isdigit():
                            break
                        if tickets[i].startswith("\tEtype"):
                            etype = tickets[i].strip()
                            break
                        if tickets[i].startswith("\trenew"):
                            flags = tickets[i].split("Flags: ")[1]
                            continue

                        extra = tickets[i].split(", ", 1)
                        flags = extra[0].strip()
                        etype = extra[1].strip()

                    parsed_klist.append({
                        'issued': issued,
                        'expires': expires,
                        'client': client,
                        'server': server,
                        'etype': etype,
                        'flags': flags,
                    })

            return {
                "ad_TGT":
                parsed_klist
                if dstype == DSType.DS_TYPE_ACTIVEDIRECTORY else [],
                "ldap_TGT":
                parsed_klist if dstype == DSType.DS_TYPE_LDAP else [],
            }

        tkts = klistin.split('\n\n')
        for tkt in tkts:
            s = tkt.splitlines()
            if len(s) > 4:
                for entry in s:
                    if "Auth time" in entry:
                        issued = time.strptime(
                            (entry.split('Auth time: '))[1].lstrip().replace(
                                '  ', ' '), '%b %d %H:%M:%S %Y')
                    elif "End time" in entry:
                        expires = time.strptime(
                            (entry.split('End time: '))[1].lstrip().replace(
                                '  ', ' '), '%b %d %H:%M:%S %Y')
                    elif "Server" in entry:
                        server = (entry.split('Server: '))[1]
                    elif "Client" in entry:
                        client = (entry.split('Client: '))[1]
                    elif 'Ticket etype' in entry:
                        etype = (entry.split('Ticket etype: '))[1]
                    elif 'Ticket flags' in entry:
                        flags = (entry.split('Ticket flags: '))[1].split(',')

                if ad['enable'] and ad['kerberos_realm'] and ad[
                        'domainname'] in client:
                    ad_TGT.append({
                        'issued': issued,
                        'expires': expires,
                        'client': client,
                        'server': server,
                        'etype': etype,
                        'flags': flags,
                    })

                elif ldap['enable'] and ldap['kerberos_realm']:
                    if ldap['kerberos_realm']['krb_realm'] in client:
                        ldap_TGT.append({
                            'issued': issued,
                            'expires': expires,
                            'client': client,
                            'server': server,
                            'etype': etype,
                            'flags': flags,
                        })

        return {"ad_TGT": ad_TGT, "ldap_TGT": ldap_TGT}

    @private
    async def _get_cached_klist(self):
        """
        Try to get retrieve cached kerberos tgt info. If it hasn't been cached,
        perform klist, parse it, put it in cache, then return it.
        """
        if await self.middleware.call('cache.has_key', 'KRB_TGT_INFO'):
            return (await self.middleware.call('cache.get', 'KRB_TGT_INFO'))
        ad = await self.middleware.call('activedirectory.config')
        ldap = await self.middleware.call('ldap.config')
        ad_TGT = []
        ldap_TGT = []
        parsed_klist = {}
        if not ad['enable'] and not ldap['enable']:
            return {'ad_TGT': ad_TGT, 'ldap_TGT': ldap_TGT}
        if not ad['enable'] and not ldap['kerberos_realm']:
            return {'ad_TGT': ad_TGT, 'ldap_TGT': ldap_TGT}

        if not await self.status():
            await self.start()
        krb5 = KRB5.platform()
        try:
            if krb5 == KRB5.HEIMDAL:
                klist = await asyncio.wait_for(run(['klist', '-v'],
                                                   check=False,
                                                   stdout=subprocess.PIPE),
                                               timeout=10.0)
            else:
                klist = await asyncio.wait_for(run(['klist', '-ef'],
                                                   check=False,
                                                   stdout=subprocess.PIPE),
                                               timeout=10.0)
        except Exception as e:
            await self.stop()
            raise CallError(
                "Attempt to list kerberos tickets failed with error: %s", e)

        if klist.returncode != 0:
            await self.stop()
            raise CallError(
                f'klist failed with error: {klist.stderr.decode()}')

        klist_output = klist.stdout.decode()

        parsed_klist = await self.parse_klist({
            "krb5type": krb5.value,
            "klistin": klist_output,
            "ad": ad,
            "ldap": ldap,
        })

        if parsed_klist['ad_TGT'] or parsed_klist['ldap_TGT']:
            await self.middleware.call('cache.put', 'KRB_TGT_INFO',
                                       parsed_klist)

        return parsed_klist

    @private
    async def renew(self):
        """
        Compare timestamp of cached TGT info with current timestamp. If we're within 5 minutes
        of expire time, renew the TGT via 'kinit -R'.
        """
        tgt_info = await self._get_cached_klist()
        ret = True

        must_renew = False
        must_reinit = False
        if not tgt_info['ad_TGT'] and not tgt_info['ldap_TGT']:
            must_reinit = True

        if tgt_info['ad_TGT']:
            permitted_buffer = datetime.timedelta(minutes=5)
            current_time = datetime.datetime.now()
            for entry in tgt_info['ad_TGT']:
                tgt_expiry_time = datetime.datetime.fromtimestamp(
                    time.mktime(entry['expires']))
                delta = tgt_expiry_time - current_time
                if datetime.timedelta(minutes=0) > delta:
                    must_reinit = True
                    break
                if permitted_buffer > delta:
                    must_renew = True
                    break

        if tgt_info['ldap_TGT']:
            permitted_buffer = datetime.timedelta(minutes=5)
            current_time = datetime.datetime.now()
            for entry in tgt_info['ldap_TGT']:
                tgt_expiry_time = datetime.datetime.fromtimestamp(
                    time.mktime(entry['expires']))
                delta = tgt_expiry_time - current_time
                if datetime.timedelta(minutes=0) > delta:
                    must_reinit = True
                    break
                if permitted_buffer > delta:
                    must_renew = True
                    break

        if must_renew and not must_reinit:
            try:
                kinit = await asyncio.wait_for(run(['kinit', '-R'],
                                                   check=False),
                                               timeout=15)
                if kinit.returncode != 0:
                    raise CallError(
                        f'kinit -R failed with error: {kinit.stderr.decode()}')
                self.logger.debug(f'Successfully renewed kerberos TGT')
                await self.middleware.call('cache.pop', 'KRB_TGT_INFO')
            except asyncio.TimeoutError:
                self.logger.debug(
                    'Attempt to renew kerberos TGT failed after 15 seconds.')

        if must_reinit:
            ret = await self.start()
            await self.middleware.call('cache.pop', 'KRB_TGT_INFO')

        return ret

    @private
    async def status(self):
        """
        Experience in production environments has indicated that klist can hang
        indefinitely. Fail if we hang for more than 10 seconds. This should force
        a kdestroy and new attempt to kinit (depending on why we are checking status).
        _klist_test will return false if there is not a TGT or if the TGT has expired.
        """
        try:
            ret = await asyncio.wait_for(self._klist_test(), timeout=10.0)
            return ret
        except asyncio.TimeoutError:
            self.logger.debug(
                'kerberos ticket status check timed out after 10 seconds.')
            return False

    @private
    async def stop(self):
        await self.middleware.call('cache.pop', 'KRB_TGT_INFO')
        kdestroy = await run(['kdestroy'], check=False)
        if kdestroy.returncode != 0:
            raise CallError(
                f'kdestroy failed with error: {kdestroy.stderr.decode()}')

        return True

    @private
    async def start(self, realm=None, kinit_timeout=30):
        """
        kinit can hang because it depends on DNS. If it has not returned within
        30 seconds, it is safe to say that it has failed.
        """
        await self.middleware.call('etc.generate', 'kerberos')
        try:
            await asyncio.wait_for(self._kinit(), timeout=kinit_timeout)
        except asyncio.TimeoutError:
            raise CallError(
                f'Timed out hung kinit after [{kinit_timeout}] seconds')
示例#15
0
class ChartReleaseService(CRUDService):
    class Config:
        datastore_primary_key_type = 'string'
        namespace = 'chart.release'
        cli_namespace = 'app.chart_release'

    @filterable
    async def query(self, filters, options):
        """
        Query available chart releases.

        `query-options.extra.retrieve_resources` is a boolean when set will retrieve existing kubernetes resources
        in the chart namespace.

        `query-options.extra.history` is a boolean when set will retrieve all chart version upgrades
        for a chart release.

        `query-options.extra.include_chart_schema` is a boolean when set will retrieve the schema being used by
        the chart release in question.
        """
        if not await self.middleware.call('service.started', 'kubernetes'):
            # We use filter_list here to ensure that `options` are respected, options like get: true
            return filter_list([], filters, options)

        update_catalog_config = {}
        catalogs = await self.middleware.call(
            'catalog.query', [], {'extra': {
                'item_details': True
            }})
        container_images = {}
        for image in await self.middleware.call('container.image.query'):
            for tag in image['repo_tags']:
                if not container_images.get(tag):
                    container_images[tag] = image

        for catalog in catalogs:
            update_catalog_config[catalog['label']] = {}
            for train in catalog['trains']:
                train_data = {}
                for catalog_item in catalog['trains'][train]:
                    versions = {
                        k: v
                        for k, v in catalog['trains'][train][catalog_item]
                        ['versions'].items() if v['healthy']
                    }
                    max_version = max([parse_version(v) for v in versions],
                                      default=parse_version('0.0.0'))
                    app_version = None
                    if str(max_version) in versions:
                        app_version = versions[str(
                            max_version)]['chart_metadata'].get('appVersion')

                    train_data[catalog_item] = {
                        'chart_version': max_version,
                        'app_version': app_version,
                    }

                update_catalog_config[catalog['label']][train] = train_data

        k8s_config = await self.middleware.call('kubernetes.config')
        k8s_node_ip = await self.middleware.call('kubernetes.node_ip')
        options = options or {}
        extra = copy.deepcopy(options.get('extra', {}))
        retrieve_schema = extra.get('include_chart_schema')
        get_resources = extra.get('retrieve_resources')
        get_history = extra.get('history')
        if retrieve_schema:
            questions_context = await self.middleware.call(
                'catalog.get_normalised_questions_context')
        else:
            questions_context = None

        if filters and len(filters) == 1 and filters[0][:2] == ['id', '=']:
            extra['namespace_filter'] = [
                'metadata.namespace', '=',
                f'{CHART_NAMESPACE_PREFIX}{filters[0][-1]}'
            ]
            resources_filters = [extra['namespace_filter']]
        else:
            resources_filters = [[
                'metadata.namespace', '^', CHART_NAMESPACE_PREFIX
            ]]

        ports_used = collections.defaultdict(list)
        for node_port_svc in await self.middleware.call(
                'k8s.service.query',
            [['spec.type', '=', 'NodePort']] + resources_filters):
            release_name = node_port_svc['metadata']['namespace'][
                len(CHART_NAMESPACE_PREFIX):]
            ports_used[release_name].extend([{
                'port': p['node_port'],
                'protocol': p['protocol']
            } for p in node_port_svc['spec']['ports']])

        storage_classes = collections.defaultdict(lambda: None)
        for storage_class in await self.middleware.call(
                'k8s.storage_class.query'):
            storage_classes[storage_class['metadata']['name']] = storage_class

        persistent_volumes = collections.defaultdict(list)

        # If the chart release was consuming any PV's, they would have to be manually removed from k8s database
        # because of chart release reclaim policy being retain
        for pv in await self.middleware.call('k8s.pv.query', [[
                'spec.csi.volume_attributes.openebs\\.io/poolname', '^',
                f'{os.path.join(k8s_config["dataset"], "releases")}/'
        ]]):
            dataset = pv['spec']['csi']['volume_attributes'][
                'openebs.io/poolname']
            rl = dataset.split('/', 4)
            if len(rl) > 4:
                persistent_volumes[rl[3]].append(pv)

        resources = {r.value: collections.defaultdict(list) for r in Resources}
        workload_status = collections.defaultdict(lambda: {
            'desired': 0,
            'available': 0
        })

        for resource in Resources:
            for r_data in await self.middleware.call(
                    f'k8s.{resource.name.lower()}.query', resources_filters):
                release_name = r_data['metadata']['namespace'][
                    len(CHART_NAMESPACE_PREFIX):]
                resources[resource.value][release_name].append(r_data)
                if resource in (Resources.DEPLOYMENT, Resources.STATEFULSET):
                    workload_status[release_name]['desired'] += (
                        r_data['status']['replicas'] or 0)
                    workload_status[release_name]['available'] += (
                        r_data['status']['ready_replicas'] or 0)

        release_secrets = await self.middleware.call(
            'chart.release.releases_secrets', extra)
        releases = []
        for name, release in release_secrets.items():
            config = {}
            release_data = release['releases'].pop(0)
            cur_version = release_data['chart_metadata']['version']

            for rel_data in filter(
                    lambda r: r['chart_metadata']['version'] == cur_version,
                    itertools.chain(reversed(release['releases']),
                                    [release_data])):
                config.update(rel_data['config'])

            pods_status = workload_status[name]
            pod_diff = pods_status['available'] - pods_status['desired']
            status = 'ACTIVE'
            if pod_diff == 0 and pods_status['desired'] == 0:
                status = 'STOPPED'
            elif pod_diff < 0:
                status = 'DEPLOYING'

            # We will retrieve all host ports being used
            for pod in filter(lambda p: p['status']['phase'] == 'Running',
                              resources[Resources.POD.value][name]):
                for container in pod['spec']['containers']:
                    ports_used[name].extend([{
                        'port': p['host_port'],
                        'protocol': p['protocol']
                    } for p in (container['ports'] or []) if p['host_port']])

            release_data.update({
                'path':
                os.path.join('/mnt', k8s_config['dataset'], 'releases', name),
                'dataset':
                os.path.join(k8s_config['dataset'], 'releases', name),
                'config':
                config,
                'status':
                status,
                'used_ports':
                ports_used[name],
                'pod_status':
                pods_status,
            })

            release_resources = {
                'storage_class':
                storage_classes[get_storage_class_name(name)],
                'persistent_volumes':
                persistent_volumes[name],
                'host_path_volumes':
                await
                self.host_path_volumes(resources[Resources.POD.value][name]),
                **{r.value: resources[r.value][name]
                   for r in Resources},
            }
            release_resources = {
                **release_resources,
                'container_images': {
                    i_name: {
                        'id':
                        image_details.get('id'),
                        'update_available':
                        image_details.get('update_available', False)
                    }
                    for i_name, image_details in map(
                        lambda i: (i, container_images.get(i, {})),
                        list(
                            set(c['image']
                                for workload_type in ('deployments',
                                                      'statefulsets') for
                                workload in release_resources[workload_type]
                                for c in workload['spec']['template']['spec']
                                ['containers'])))
                },
                'truenas_certificates': [
                    v['id'] for v in release_data['config'].get(
                        'ixCertificates', {}).values()
                ],
                'truenas_certificate_authorities': [
                    v['id'] for v in release_data['config'].get(
                        'ixCertificateAuthorities', {}).values()
                ],
            }
            if get_resources:
                release_data['resources'] = release_resources

            if get_history:
                release_data['history'] = release['history']

            current_version = parse_version(
                release_data['chart_metadata']['version'])
            catalog_version_dict = update_catalog_config.get(
                release_data['catalog'],
                {}).get(release_data['catalog_train'],
                        {}).get(release_data['chart_metadata']['name'], {})
            latest_version = catalog_version_dict.get('chart_version',
                                                      current_version)
            latest_app_version = catalog_version_dict.get('app_version')
            release_data['update_available'] = latest_version > current_version

            app_version = None
            if release_data['chart_metadata']['name'] == 'ix-chart':
                image_config = release_data['config'].get('image') or {}
                if all(k in image_config for k in ('tag', 'repository')):
                    # TODO: Let's see if we can find sane versioning for `latest` from upstream
                    if image_config['tag'] == 'latest':
                        app_version = f'{image_config["repository"]}:{image_config["tag"]}'
                    else:
                        app_version = image_config['tag']
                # Latest app version for ix-chart remains same
                latest_app_version = app_version
            else:
                app_version = release_data['chart_metadata'].get('appVersion')

            for key, app_v, c_v in (
                ('human_version', app_version, current_version),
                ('human_latest_version', latest_app_version, latest_version),
            ):
                if app_v:
                    release_data[key] = f'{app_v}_{c_v}'
                else:
                    release_data[key] = str(c_v)

            if retrieve_schema:
                chart_path = os.path.join(
                    release_data['path'], 'charts',
                    release_data['chart_metadata']['version'])
                if os.path.exists(chart_path):
                    release_data['chart_schema'] = await self.middleware.call(
                        'catalog.item_version_details', chart_path,
                        questions_context)
                else:
                    release_data['chart_schema'] = None

            release_data['container_images_update_available'] = any(
                details['update_available']
                for details in release_resources['container_images'].values())
            release_data['chart_metadata']['latest_chart_version'] = str(
                latest_version)
            release_data['portals'] = await self.middleware.call(
                'chart.release.retrieve_portals_for_chart_release',
                release_data, k8s_node_ip)

            if 'icon' not in release_data['chart_metadata']:
                release_data['chart_metadata']['icon'] = None

            releases.append(release_data)

        return filter_list(releases, filters, options)

    @private
    def retrieve_portals_for_chart_release(self, release_data, node_ip=None):
        questions_yaml_path = os.path.join(
            release_data['path'], 'charts',
            release_data['chart_metadata']['version'], 'questions.yaml')
        if not os.path.exists(questions_yaml_path):
            return {}

        with open(questions_yaml_path, 'r') as f:
            portals = yaml.safe_load(f.read()).get('portals') or {}

        if not portals:
            return portals

        if not node_ip:
            node_ip = self.middleware.call_sync('kubernetes.node_ip')

        def tag_func(key):
            return self.parse_tag(release_data, key, node_ip)

        cleaned_portals = {}
        for portal_type, schema in portals.items():
            t_portals = []
            path = schema.get('path') or '/'
            for protocol in filter(bool, map(tag_func, schema['protocols'])):
                for host in filter(bool, map(tag_func, schema['host'])):
                    for port in filter(bool, map(tag_func, schema['ports'])):
                        t_portals.append(f'{protocol}://{host}:{port}{path}')

            cleaned_portals[portal_type] = t_portals

        return cleaned_portals

    @private
    def parse_tag(self, release_data, tag, node_ip):
        tag = self.parse_k8s_resource_tag(release_data, tag)
        if not tag:
            return
        if tag == '$node_ip':
            return node_ip
        elif tag.startswith('$variable-'):
            return get(release_data['config'], tag[len('$variable-'):])

        return tag

    @private
    def parse_k8s_resource_tag(self, release_data, tag):
        # Format expected here is "$kubernetes-resource_RESOURCE-TYPE_RESOURCE-NAME_KEY-NAME"
        if not tag.startswith('$kubernetes-resource'):
            return tag

        if tag.count('_') < 3:
            return

        _, resource_type, resource_name, key = tag.split('_', 3)
        if resource_type not in ('configmap', 'secret'):
            return

        resource = self.middleware.call_sync(
            f'k8s.{resource_type}.query',
            [['metadata.namespace', '=', release_data['namespace']],
             ['metadata.name', '=', resource_name]])
        if not resource or 'data' not in resource[0] or not isinstance(
                resource[0]['data'].get(key), (int, str)):
            # Chart creator did not create the resource or we have a malformed
            # secret/configmap, nothing we can do on this end
            return
        else:
            value = resource[0]['data'][key]

        if resource_type == 'secret':
            value = base64.b64decode(value)

        return str(value)

    @private
    async def host_path_volumes(self, pods):
        host_path_volumes = []
        for pod in pods:
            for volume in filter(lambda v: v.get('host_path'),
                                 pod['spec']['volumes'] or []):
                host_path_volumes.append(copy.deepcopy(volume))
        return host_path_volumes

    @private
    async def normalise_and_validate_values(self,
                                            item_details,
                                            values,
                                            update,
                                            release_ds,
                                            release_data=None):
        dict_obj = await self.middleware.call(
            'chart.release.validate_values',
            item_details,
            values,
            update,
            release_data,
        )
        return await self.middleware.call(
            'chart.release.get_normalised_values', dict_obj, values, update, {
                'release': {
                    'name': release_ds.split('/')[-1],
                    'dataset': release_ds,
                    'path': os.path.join('/mnt', release_ds),
                },
                'actions': [],
            })

    @private
    async def perform_actions(self, context):
        for action in context['actions']:
            await self.middleware.call(f'chart.release.{action["method"]}',
                                       *action['args'])

    @accepts(
        Dict(
            'chart_release_create',
            Dict('values', additional_attrs=True),
            Str('catalog', required=True),
            Str('item', required=True),
            Str('release_name',
                required=True,
                validators=[Match(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')]),
            Str('train', default='charts'),
            Str('version', default='latest'),
        ))
    @job(lock=lambda args: f'chart_release_create_{args[0]["release_name"]}')
    async def do_create(self, job, data):
        """
        Create a chart release for a catalog item.

        `release_name` is the name which will be used to identify the created chart release.

        `catalog` is a valid catalog id where system will look for catalog `item` details.

        `train` is which train to look for under `catalog` i.e stable / testing etc.

        `version` specifies the catalog `item` version.

        `values` is configuration specified for the catalog item version in question which will be used to
        create the chart release.
        """
        await self.middleware.call('kubernetes.validate_k8s_setup')
        if await self.query([['id', '=', data['release_name']]]):
            raise CallError(
                f'Chart release with {data["release_name"]} already exists.',
                errno=errno.EEXIST)

        catalog = await self.middleware.call('catalog.query',
                                             [['id', '=', data['catalog']]],
                                             {'extra': {
                                                 'item_details': True
                                             }})
        if not catalog:
            raise CallError(f'Unable to locate {data["catalog"]!r} catalog',
                            errno=errno.ENOENT)
        else:
            catalog = catalog[0]
        if data['train'] not in catalog['trains']:
            raise CallError(
                f'Unable to locate "{data["train"]}" catalog train.',
                errno=errno.ENOENT)
        if data['item'] not in catalog['trains'][data['train']]:
            raise CallError(f'Unable to locate "{data["item"]}" catalog item.',
                            errno=errno.ENOENT)

        version = data['version']
        if version == 'latest':
            version = await self.middleware.call(
                'chart.release.get_latest_version_from_item_versions',
                catalog['trains'][data['train']][data['item']]['versions'])

        if version not in catalog['trains'][data['train']][
                data['item']]['versions']:
            raise CallError(
                f'Unable to locate "{data["version"]}" catalog item version.',
                errno=errno.ENOENT)

        item_details = catalog['trains'][data['train']][
            data['item']]['versions'][version]
        await self.middleware.call('catalog.version_supported_error_check',
                                   item_details)

        k8s_config = await self.middleware.call('kubernetes.config')
        release_ds = os.path.join(k8s_config['dataset'], 'releases',
                                  data['release_name'])
        # The idea is to validate the values provided first and if it passes our validation test, we
        # can move forward with setting up the datasets and installing the catalog item
        new_values = data['values']
        new_values, context = await self.normalise_and_validate_values(
            item_details, new_values, False, release_ds)

        job.set_progress(25, 'Initial Validation completed')

        # Now that we have completed validation for the item in question wrt values provided,
        # we will now perform the following steps
        # 1) Create release datasets
        # 2) Copy chart version into release/charts dataset
        # 3) Install the helm chart
        # 4) Create storage class
        try:
            job.set_progress(30, 'Creating chart release datasets')

            for dataset in await self.release_datasets(release_ds):
                if not await self.middleware.call('zfs.dataset.query',
                                                  [['id', '=', dataset]]):
                    await self.middleware.call('zfs.dataset.create', {
                        'name': dataset,
                        'type': 'FILESYSTEM'
                    })
                    await self.middleware.call('zfs.dataset.mount', dataset)

            job.set_progress(45, 'Created chart release datasets')

            chart_path = os.path.join('/mnt', release_ds, 'charts', version)
            await self.middleware.run_in_thread(
                lambda: shutil.copytree(item_details['location'], chart_path))

            job.set_progress(55, 'Completed setting up chart release')
            # Before finally installing the release, we will perform any actions which might be required
            # for the release to function like creating/deleting ix-volumes
            await self.perform_actions(context)

            namespace_name = get_namespace(data['release_name'])

            job.set_progress(65,
                             f'Creating {namespace_name} for chart release')
            namespace_body = {
                'metadata': {
                    'labels': {
                        'catalog': data['catalog'],
                        'catalog_train': data['train'],
                        'catalog_branch': catalog['branch'],
                    },
                    'name': namespace_name,
                }
            }
            if not await self.middleware.call(
                    'k8s.namespace.query',
                [['metadata.name', '=', namespace_name]]):
                await self.middleware.call('k8s.namespace.create',
                                           {'body': namespace_body})
            else:
                await self.middleware.call('k8s.namespace.update',
                                           namespace_name,
                                           {'body': namespace_body})

            job.set_progress(75, 'Installing Catalog Item')

            new_values[CONTEXT_KEY_NAME].update({
                **get_action_context(data['release_name']),
                'operation':
                'INSTALL',
                'isInstall':
                True,
            })

            # We will install the chart now and force the installation in an ix based namespace
            # https://github.com/helm/helm/issues/5465#issuecomment-473942223
            await self.middleware.call('chart.release.helm_action',
                                       data['release_name'], chart_path,
                                       new_values, 'install')

            await self.middleware.call(
                'chart.release.create_update_storage_class_for_chart_release',
                data['release_name'], os.path.join(release_ds, 'volumes'))
        except Exception:
            # Do a rollback here
            # Let's uninstall the release as well if it did get installed ( it is possible this might have happened )
            if await self.middleware.call('chart.release.query',
                                          [['id', '=', data['release_name']]]):
                delete_job = await self.middleware.call(
                    'chart.release.delete', data['release_name'])
                await delete_job.wait()
                if delete_job.error:
                    self.logger.error(
                        'Failed to uninstall helm chart release: %s',
                        delete_job.error)
            else:
                await self.post_remove_tasks(data['release_name'])

            raise
        else:
            await self.middleware.call('chart.release.refresh_events_state',
                                       data['release_name'])
            job.set_progress(100, 'Chart release created')
            return await self.get_instance(data['release_name'])

    @accepts(Str('chart_release'),
             Dict(
                 'chart_release_update',
                 Dict('values', additional_attrs=True),
             ))
    @job(lock=lambda args: f'chart_release_update_{args[0]}')
    async def do_update(self, job, chart_release, data):
        """
        Update an existing chart release.

        `values` is configuration specified for the catalog item version in question which will be used to
        create the chart release.
        """
        release = await self.get_instance(chart_release)
        release_orig = copy.deepcopy(release)
        chart_path = os.path.join(release['path'], 'charts',
                                  release['chart_metadata']['version'])
        if not os.path.exists(chart_path):
            raise CallError(
                f'Unable to locate {chart_path!r} chart version for updating {chart_release!r} chart release',
                errno=errno.ENOENT)

        version_details = await self.middleware.call(
            'catalog.item_version_details', chart_path)
        config = release['config']
        config.update(data['values'])
        # We use update=False because we want defaults to be populated again if they are not present in the payload
        # Why this is not dangerous is because the defaults will be added only if they are not present/configured for
        # the chart release.
        config, context = await self.normalise_and_validate_values(
            version_details,
            config,
            False,
            release['dataset'],
            release_orig,
        )

        job.set_progress(25, 'Initial Validation complete')

        await self.perform_actions(context)

        config[CONTEXT_KEY_NAME].update({
            **get_action_context(chart_release),
            'operation':
            'UPDATE',
            'isUpdate':
            True,
        })

        await self.middleware.call('chart.release.helm_action', chart_release,
                                   chart_path, config, 'update')

        job.set_progress(90, 'Syncing secrets for chart release')
        await self.middleware.call('chart.release.sync_secrets_for_release',
                                   chart_release)

        job.set_progress(100, 'Update completed for chart release')
        return await self.get_instance(chart_release)

    @accepts(Str('release_name'))
    @job(lock=lambda args: f'chart_release_delete_{args[0]}')
    async def do_delete(self, job, release_name):
        """
        Delete existing chart release.

        This will delete the chart release from the kubernetes cluster and also remove any associated volumes / data.
        To clarify, host path volumes will not be deleted which live outside the chart release dataset.
        """
        # For delete we will uninstall the release first and then remove the associated datasets
        await self.middleware.call('kubernetes.validate_k8s_setup')
        await self.get_instance(release_name)

        cp = await run([
            'helm', 'uninstall', release_name, '-n',
            get_namespace(release_name)
        ],
                       check=False)
        if cp.returncode:
            raise CallError(
                f'Unable to uninstall "{release_name}" chart release: {cp.stderr}'
            )

        job.set_progress(50, f'Uninstalled {release_name}')
        job.set_progress(75, f'Waiting for {release_name!r} pods to terminate')
        await self.middleware.call('chart.release.wait_for_pods_to_terminate',
                                   get_namespace(release_name))

        await self.post_remove_tasks(release_name, job)

        await self.middleware.call(
            'chart.release.remove_chart_release_from_events_state',
            release_name)
        await self.middleware.call('alert.oneshot_delete',
                                   'ChartReleaseUpdate', release_name)

        job.set_progress(100, f'{release_name!r} chart release deleted')
        return True

    @private
    def helm_action(self, chart_release, chart_path, config, tn_action):
        args = ['-f']
        if os.path.exists(os.path.join(chart_path, 'ix_values.yaml')):
            args.extend([os.path.join(chart_path, 'ix_values.yaml'), '-f'])

        action = tn_action if tn_action == 'install' else 'upgrade'

        with tempfile.NamedTemporaryFile(mode='w+') as f:
            f.write(yaml.dump(config))
            f.flush()

            cp = subprocess.Popen(
                [
                    'helm', action, chart_release, chart_path, '-n',
                    get_namespace(chart_release)
                ] + args + [f.name],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
                env=dict(os.environ, KUBECONFIG='/etc/rancher/k3s/k3s.yaml'),
            )
            stderr = cp.communicate()[1]
            if cp.returncode:
                raise CallError(
                    f'Failed to {tn_action} chart release: {stderr.decode()}')

    @accepts(Str('release_name'))
    @job(lock=lambda args: f'chart_release_redeploy_{args[0]}')
    async def redeploy(self, job, release_name):
        """
        Redeploy will initiate a rollout of new pods according to upgrade strategy defined by the chart release
        workloads. A good example for redeploying is updating kubernetes pods with an updated container image.
        """
        update_job = await self.middleware.call('chart.release.update',
                                                release_name, {'values': {}})
        return await job.wrap(update_job)

    @private
    async def post_remove_tasks(self, release_name, job=None):
        await self.remove_storage_class_and_dataset(release_name, job)
        await self.middleware.call('k8s.namespace.delete',
                                   get_namespace(release_name))

    @private
    async def remove_storage_class_and_dataset(self, release_name, job=None):
        storage_class_name = get_storage_class_name(release_name)
        if await self.middleware.call(
                'k8s.storage_class.query',
            [['metadata.name', '=', storage_class_name]]):
            if job:
                job.set_progress(85,
                                 f'Removing {release_name!r} storage class')
            try:
                await self.middleware.call('k8s.storage_class.delete',
                                           storage_class_name)
            except Exception as e:
                self.logger.error('Failed to remove %r storage class: %s',
                                  storage_class_name, e)

        k8s_config = await self.middleware.call('kubernetes.config')
        release_ds = os.path.join(k8s_config['dataset'], 'releases',
                                  release_name)

        # If the chart release was consuming any PV's, they would have to be manually removed from k8s database
        # because of chart release reclaim policy being retain
        for pv in await self.middleware.call('k8s.pv.query', [[
                'spec.csi.volume_attributes.openebs\\.io/poolname', '=',
                os.path.join(release_ds, 'volumes')
        ]]):
            await self.middleware.call('k8s.pv.delete', pv['metadata']['name'])

        if await self.middleware.call('zfs.dataset.query',
                                      [['id', '=', release_ds]]):
            if job:
                job.set_progress(95, f'Removing {release_ds!r} dataset')
            await self.middleware.call('zfs.dataset.delete', release_ds, {
                'recursive': True,
                'force': True
            })

    @private
    async def release_datasets(self, release_dataset):
        return [release_dataset] + [
            os.path.join(release_dataset, k)
            for k in ('charts', 'volumes', 'volumes/ix_volumes')
        ]

    @private
    async def get_chart_namespace_prefix(self):
        return CHART_NAMESPACE_PREFIX
示例#16
0
class UpdateService(Service):
    @accepts()
    async def get_auto_download(self):
        """
        Returns if update auto-download is enabled.
        """
        return (await self.middleware.call('datastore.config',
                                           'system.update'))['upd_autocheck']

    @accepts(Bool('autocheck'))
    async def set_auto_download(self, autocheck):
        """
        Sets if update auto-download is enabled.
        """
        config = await self.middleware.call('datastore.config',
                                            'system.update')
        await self.middleware.call('datastore.update', 'system.update',
                                   config['id'], {'upd_autocheck': autocheck})
        await self.middleware.call('service.restart', 'cron')

    @accepts()
    def get_trains(self):
        """
        Returns available trains dict and the currently configured train as well as the
        train of currently booted environment.
        """

        self.middleware.call_sync('network.general.will_perform_activity',
                                  'update')

        data = self.middleware.call_sync('datastore.config', 'system.update')

        trains_data = self.middleware.call_sync('update.get_trains_data')
        current_train = trains_data['current_train']
        trains = trains_data['trains']
        selected = None
        for name, train in list(trains.items()):
            try:
                result = compare_trains(current_train, name)
            except Exception:
                self.logger.warning("Failed to compare trains %r and %r",
                                    current_train,
                                    name,
                                    exc_info=True)
                continue
            else:
                if result in BAD_UPGRADES:
                    trains.pop(name)
                    continue

            if not selected and data['upd_train'] == name:
                selected = data['upd_train']
            if name in trains_data['trains_redirection']:
                trains.pop(name)
                continue
        if not data['upd_train'] or not selected:
            selected = current_train

        if selected in trains_data['trains_redirection']:
            selected = trains_data['trains_redirection'][selected]
        return {
            'trains': trains,
            'current': current_train,
            'selected': selected,
        }

    @accepts(Str('train', empty=False))
    def set_train(self, train):
        """
        Set an update train to be used by default in updates.
        """
        return self.__set_train(train)

    def __set_train(self, train, trains=None):
        """
        Wrapper so we don't call get_trains twice on update method.
        """
        if trains is None:
            trains = self.get_trains()
        if train != trains['selected']:
            if train not in trains['trains']:
                raise CallError('Invalid train name.', errno.ENOENT)

            try:
                result = compare_trains(trains['current'], train)
            except Exception:
                self.logger.warning("Failed to compare trains %r and %r",
                                    trains['current'],
                                    train,
                                    exc_info=True)
            else:
                if result in BAD_UPGRADES:
                    raise CallError(BAD_UPGRADES[result])

            data = self.middleware.call_sync('datastore.config',
                                             'system.update')
            if data['upd_train'] != train:
                self.middleware.call_sync('datastore.update', 'system.update',
                                          data['id'], {'upd_train': train})

        return True

    @accepts(
        Dict(
            'update-check-available',
            Str('train', required=False),
            required=False,
        ))
    def check_available(self, attrs=None):
        """
        Checks if there is an update available from update server.

        status:
          - REBOOT_REQUIRED: an update has already been applied
          - AVAILABLE: an update is available
          - UNAVAILABLE: no update available
          - HA_UNAVAILABLE: HA is non-functional

        .. examples(websocket)::

          Check available update using default train:

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "update.check_available"
            }
        """

        try:
            applied = self.middleware.call_sync('cache.get', 'update.applied')
        except Exception:
            applied = False
        if applied is True:
            return {'status': 'REBOOT_REQUIRED'}

        if self.middleware.call_sync('failover.licensed'):

            # First, let's make sure HA is functional
            if self.middleware.call_sync('failover.disabled_reasons'):
                return {'status': 'HA_UNAVAILABLE'}

            # If its HA and standby is running old version we assume
            # legacy upgrade and check update on standby.
            try:
                self.middleware.call_sync(
                    'failover.call_remote',
                    'failover.upgrade_version',
                )
            except CallError as e:
                if e.errno != CallError.ENOMETHOD:
                    raise
                return self.middleware.call_sync(
                    'failover.call_remote',
                    'update.check_available',
                    [attrs],
                )

        trains = self.middleware.call_sync('update.get_trains')
        train = (attrs or {}).get('train')
        if not train:
            train = trains['selected']
        elif train not in trains['trains']:
            raise CallError('Invalid train name.', errno.ENOENT)

        return self.middleware.call_sync('update.check_train', train)

    @accepts(Str('path', null=True, default=None))
    async def get_pending(self, path=None):
        """
        Gets a list of packages already downloaded and ready to be applied.
        Each entry of the lists consists of type of operation and name of it, e.g.

          {
            "operation": "upgrade",
            "name": "baseos-11.0 -> baseos-11.1"
          }
        """
        if path is None:
            path = await self.middleware.call('update.get_update_location')

        return await self.middleware.call('update.get_pending_in_path', path)

    @accepts(
        Dict(
            'update',
            Str('train', required=False),
            Bool('reboot', default=False),
            required=False,
        ))
    @job(lock='update')
    async def update(self, job, attrs=None):
        """
        Downloads (if not already in cache) and apply an update.
        """
        attrs = attrs or {}

        trains = await self.middleware.call('update.get_trains')
        train = attrs.get('train') or trains['selected']

        if attrs.get('train'):
            await self.middleware.run_in_thread(self.__set_train,
                                                attrs.get('train'), trains)

        location = await self.middleware.call('update.get_update_location')

        update = await self.middleware.call('update.download_update', job,
                                            train, location, 50)
        if update is False:
            raise ValueError('No update available')

        await self.middleware.call('update.install_impl', job, location)
        await self.middleware.call('cache.put', 'update.applied', True)
        await self.middleware.call_hook('update.post_update')

        if attrs.get('reboot'):
            await self.middleware.call('system.reboot', {'delay': 10})
        return True

    @accepts()
    @job(lock='updatedownload')
    def download(self, job):
        """
        Download updates using selected train.
        """
        train = self.middleware.call_sync('update.get_trains')['selected']
        location = self.middleware.call_sync('update.get_update_location')

        return self.middleware.call_sync('update.download_update', job, train,
                                         location, 100)

    @private
    async def download_update(self, *args):
        await self.middleware.call('network.general.will_perform_activity',
                                   'update')
        success = await self.middleware.call('update.download_impl', *args)
        await self.middleware.call('alert.alert_source_clear_run', 'HasUpdate')
        return success

    @accepts(Str('path'))
    @job(lock='updatemanual')
    def manual(self, job, path):
        """
        Update the system using a manual update file.

        `path` must be the absolute path to the update file.
        """

        update_file = pathlib.Path(path)

        # make sure absolute path was given
        if not update_file.is_absolute():
            raise CallError('Absolute path must be provided.', errno.ENOENT)

        # make sure file exists
        if not update_file.exists():
            raise CallError('File does not exist.', errno.ENOENT)

        # dest_extracted is only used on freebsd and ignored on linux
        dest_extracted = os.path.join(str(update_file.parent), '.update')

        try:
            try:
                self.middleware.call_sync('update.install_manual_impl', job,
                                          str(update_file.absolute()),
                                          dest_extracted)
            except Exception as e:
                self.logger.debug('Applying manual update failed',
                                  exc_info=True)
                raise CallError(str(e), errno.EFAULT)

            job.set_progress(95, 'Cleaning up')
        finally:
            if os.path.exists(path):
                os.unlink(path)

            if os.path.exists(dest_extracted):
                shutil.rmtree(dest_extracted, ignore_errors=True)

        if path.startswith(UPLOAD_LOCATION):
            self.middleware.call_sync('update.destroy_upload_location')

        self.middleware.call_hook_sync('update.post_update')

    @accepts(Dict(
        'updatefile',
        Str('destination', null=True),
    ))
    @job(lock='updatemanual', pipes=['input'])
    async def file(self, job, options):
        """
        Updates the system using the uploaded .tar file.

        Use null `destination` to create a temporary location.
        """

        dest = options.get('destination')

        if not dest:
            try:
                await self.middleware.call('update.create_upload_location')
                dest = UPLOAD_LOCATION
            except Exception as e:
                raise CallError(str(e))
        elif not dest.startswith('/mnt/'):
            raise CallError('Destination must reside within a pool')

        if not os.path.isdir(dest):
            raise CallError('Destination is not a directory')

        destfile = os.path.join(dest, 'manualupdate.tar')
        dest_extracted = os.path.join(dest, '.update')

        try:
            job.set_progress(10, 'Writing uploaded file to disk')
            with open(destfile, 'wb') as f:
                await self.middleware.run_in_thread(
                    shutil.copyfileobj,
                    job.pipes.input.r,
                    f,
                    1048576,
                )

            await self.middleware.call('update.install_manual_impl', job,
                                       destfile, dest_extracted)

            job.set_progress(95, 'Cleaning up')
        finally:
            if os.path.exists(destfile):
                os.unlink(destfile)

            if os.path.exists(dest_extracted):
                shutil.rmtree(dest_extracted, ignore_errors=True)

        if dest == UPLOAD_LOCATION:
            await self.middleware.call('update.destroy_upload_location')

        await self.middleware.call_hook('update.post_update')

        job.set_progress(100, 'Update completed')

    @private
    async def get_update_location(self):
        syspath = (await self.middleware.call('systemdataset.config'))['path']
        if syspath:
            path = f'{syspath}/update'
        else:
            path = UPLOAD_LOCATION
        os.makedirs(path, exist_ok=True)
        return path

    @private
    def take_systemdataset_samba4_snapshot(self):
        basename = self.middleware.call_sync(
            'systemdataset.config')['basename']
        if basename is None:
            self.logger.warning(
                'System dataset is not available, not taking snapshot')
            return

        dataset = f'{basename}/samba4'

        proc = subprocess.run([
            'zfs', 'list', '-t', 'snapshot', '-H', '-o', 'name', '-s', 'name',
            '-d', '1', dataset
        ],
                              stdout=subprocess.PIPE,
                              stderr=subprocess.PIPE,
                              encoding='utf8',
                              errors='ignore')
        if proc.returncode != 0:
            self.logger.warning('Unable to list dataset %s snapshots: %s',
                                dataset, proc.stderr)
            return

        snapshots = [s.split('@')[1] for s in proc.stdout.strip().split()]
        for snapshot in [s for s in snapshots
                         if s.startswith('update--')][:-4]:
            self.logger.info('Deleting dataset %s snapshot %s', dataset,
                             snapshot)
            subprocess.run(['zfs', 'destroy', f'{dataset}@{snapshot}'])

        current_version = "-".join(
            self.middleware.call_sync("system.info")["version"].split("-")[1:])
        snapshot = f'update--{datetime.utcnow().strftime("%Y-%m-%d-%H-%M")}--{current_version}'
        subprocess.run(['zfs', 'snapshot', f'{dataset}@{snapshot}'])
示例#17
0
class FailoverService(Service):

    CLIENT = RemoteClient()

    @private
    async def remote_ip(self):
        node = await self.middleware.call('failover.node')
        if node == 'A':
            remote = '169.254.10.2'
        elif node == 'B':
            remote = '169.254.10.1'
        else:
            raise CallError(f'Node {node} invalid for call_remote', errno.EBADRPC)
        return remote

    @accepts(
        Str('method'),
        List('args', default=[]),
        Dict(
            'options',
            Int('timeout'),
            Bool('job', default=False),
            Bool('job_return', default=None, null=True),
            Any('callback'),
        ),
    )
    def call_remote(self, method, args, options=None):
        """
        Call a method in the other node.
        """
        options = options or {}
        job_return = options.get('job_return')
        if job_return is not None:
            options['job'] = 'RETURN'
        try:
            return self.CLIENT.call(method, *args, **options)
        except CallTimeout:
            raise CallError('Call timeout', errno.ETIMEDOUT)

    @private
    def sendfile(self, token, src, dst):
        self.CLIENT.sendfile(token, src, dst)

    @private
    async def ensure_remote_client(self):
        if self.CLIENT.remote_ip is not None:
            return
        try:
            self.CLIENT.remote_ip = await self.middleware.call('failover.remote_ip')
            self.CLIENT.middleware = self.middleware
            start_daemon_thread(target=self.CLIENT.run)
        except CallError:
            pass

    @private
    def remote_connected(self):
        return self.CLIENT.is_connected()

    @private
    def remote_subscribe(self, name, callback):
        self.CLIENT.subscribe(name, callback)

    @private
    def remote_on_connect(self, callback):
        self.CLIENT.register_connect(callback)

    @private
    def remote_on_disconnect(self, callback):
        self.CLIENT.register_disconnect(callback)
示例#18
0
class CoreService(Service):
    @accepts(Str('id'), Int('cols'), Int('rows'))
    async def resize_shell(self, id, cols, rows):
        """
        Resize terminal session (/websocket/shell) to cols x rows
        """
        shell = middlewared.main.ShellApplication.shells.get(id)
        if shell is None:
            raise CallError('Shell does not exist', errno.ENOENT)

        shell.resize(cols, rows)

    @filterable
    def sessions(self, filters=None, options=None):
        """
        Get currently open websocket sessions.
        """
        return filter_list([{
            'id':
            i.session_id,
            'socket_family':
            socket.AddressFamily(
                i.request.transport.get_extra_info('socket').family).name,
            'address': ((i.request.headers.get('X-Real-Remote-Addr'),
                         i.request.headers.get('X-Real-Remote-Port'))
                        if i.request.headers.get('X-Real-Remote-Addr') else
                        (i.request.transport.get_extra_info("peername"))),
            'authenticated':
            i.authenticated,
            'call_count':
            i._softhardsemaphore.counter,
        } for i in self.middleware.get_wsclients().values()], filters, options)

    @private
    def get_tasks(self):
        for task in asyncio.all_tasks(loop=self.middleware.loop):
            formatted = None
            frame = None
            frames = []
            for frame in task.get_stack():
                cur_frame = get_frame_details(frame, self.logger)
                if cur_frame:
                    frames.append(cur_frame)

            if frame:
                formatted = traceback.format_stack(frame)
            yield {
                'stack': formatted,
                'frames': frames,
            }

    @filterable
    def get_jobs(self, filters=None, options=None):
        """Get the long running jobs."""
        jobs = filter_list([
            i.__encode__() for i in list(self.middleware.jobs.all().values())
        ], filters, options)
        return jobs

    @accepts(Int('id'))
    @job()
    def job_wait(self, job, id):
        target_job = self.middleware.jobs.get(id)
        target_job.wait_sync()
        if target_job.error:
            raise CallError(target_job.error)
        else:
            return target_job.result

    @accepts(Int('id'),
             Dict(
                 'job-update',
                 Dict('progress', additional_attrs=True),
             ))
    def job_update(self, id, data):
        job = self.middleware.jobs.all()[id]
        progress = data.get('progress')
        if progress:
            job.set_progress(
                progress['percent'],
                description=progress.get('description'),
                extra=progress.get('extra'),
            )

    @private
    def notify_postinit(self):
        self.middleware.call_sync('migration.run')

        # Sentinel file to tell we have gone far enough in the boot process.
        # See #17508
        open('/tmp/.bootready', 'w').close()

        # Send event to middlewared saying we are late enough in the process to call it ready
        self.middleware.call_sync('core.event_send', 'system', 'ADDED',
                                  {'id': 'ready'})

        # Let's setup periodic tasks now
        self.middleware._setup_periodic_tasks()

    @accepts(Int('id'))
    def job_abort(self, id):
        job = self.middleware.jobs.all()[id]
        return job.abort()

    @accepts()
    def get_services(self):
        """Returns a list of all registered services."""
        services = {}
        for k, v in list(self.middleware.get_services().items()):
            if v._config.private is True:
                continue
            if is_service_class(v, CRUDService):
                _typ = 'crud'
            elif is_service_class(v, ConfigService):
                _typ = 'config'
            else:
                _typ = 'service'
            services[k] = {
                'config': {
                    k: v
                    for k, v in list(v._config.__dict__.items())
                    if not k.startswith(('_', 'process_pool', 'thread_pool'))
                },
                'type': _typ,
            }
        return services

    @accepts(Str('service', default=None, null=True))
    def get_methods(self, service=None):
        """Return methods metadata of every available service.

        `service` parameter is optional and filters the result for a single service."""
        data = {}
        for name, svc in list(self.middleware.get_services().items()):
            if service is not None and name != service:
                continue

            # Skip private services
            if svc._config.private:
                continue

            for attr in dir(svc):

                if attr.startswith('_'):
                    continue

                method = None
                # For CRUD.do_{update,delete} they need to be accounted
                # as "item_method", since they are just wrapped.
                item_method = None
                if is_service_class(svc, CRUDService):
                    """
                    For CRUD the create/update/delete are special.
                    The real implementation happens in do_create/do_update/do_delete
                    so thats where we actually extract pertinent information.
                    """
                    if attr in ('create', 'update', 'delete'):
                        method = getattr(svc, 'do_{}'.format(attr), None)
                        if method is None:
                            continue
                        if attr in ('update', 'delete'):
                            item_method = True
                    elif attr in ('do_create', 'do_update', 'do_delete'):
                        continue
                elif is_service_class(svc, ConfigService):
                    """
                    For Config the update is special.
                    The real implementation happens in do_update
                    so thats where we actually extract pertinent information.
                    """
                    if attr == 'update':
                        original_name = 'do_{}'.format(attr)
                        if hasattr(svc, original_name):
                            method = getattr(svc, original_name, None)
                        else:
                            method = getattr(svc, attr)
                        if method is None:
                            continue
                    elif attr in ('do_update'):
                        continue

                if method is None:
                    method = getattr(svc, attr, None)

                if method is None or not callable(method):
                    continue

                # Skip private methods
                if hasattr(method, '_private'):
                    continue

                # terminate is a private method used to clean up a service on shutdown
                if attr == 'terminate':
                    continue

                examples = defaultdict(list)
                doc = inspect.getdoc(method)
                if doc:
                    """
                    Allow method docstring to have sections in the format of:

                      .. section_name::

                    Currently the following sections are available:

                      .. examples:: - goes into `__all__` list in examples
                      .. examples(rest):: - goes into `rest` list in examples
                      .. examples(websocket):: - goes into `websocket` list in examples
                    """
                    sections = re.split(r'^.. (.+?)::$', doc, flags=re.M)
                    doc = sections[0]
                    for i in range(int((len(sections) - 1) / 2)):
                        idx = (i + 1) * 2 - 1
                        reg = re.search(r'examples(?:\((.+)\))?',
                                        sections[idx])
                        if reg is None:
                            continue
                        exname = reg.groups()[0]
                        if exname is None:
                            exname = '__all__'
                        examples[exname].append(sections[idx + 1])

                accepts = getattr(method, 'accepts', None)
                if accepts:
                    accepts = [
                        i.to_json_schema() for i in accepts
                        if not getattr(i, 'hidden', False)
                    ]

                data['{0}.{1}'.format(name, attr)] = {
                    'description':
                    doc,
                    'examples':
                    examples,
                    'accepts':
                    accepts,
                    'item_method':
                    True if item_method else hasattr(method, '_item_method'),
                    'no_auth_required':
                    hasattr(method, '_no_auth_required'),
                    'filterable':
                    hasattr(method, '_filterable'),
                    'pass_application':
                    hasattr(method, '_pass_app'),
                    'require_websocket':
                    hasattr(method, '_pass_app')
                    and not method._pass_app['rest'],
                    'job':
                    hasattr(method, '_job'),
                    'downloadable':
                    hasattr(method, '_job')
                    and 'output' in method._job['pipes'],
                    'uploadable':
                    hasattr(method, '_job')
                    and 'input' in method._job['pipes'],
                    'require_pipes':
                    hasattr(method, '_job') and method._job['check_pipes']
                    and any(i in method._job['pipes']
                            for i in ('input', 'output')),
                }
        return data

    @accepts()
    def get_events(self):
        """
        Returns metadata for every possible event emitted from websocket server.
        """
        events = {}
        for name, attrs in self.middleware.get_events():
            if attrs['private']:
                continue

            events[name] = {
                'description': attrs['description'],
                'wildcard_subscription': attrs['wildcard_subscription'],
            }

        return events

    @private
    async def call_hook(self, name, args, kwargs=None):
        kwargs = kwargs or {}
        await self.middleware.call_hook(name, *args, **kwargs)

    @private
    async def event_send(self, name, event_type, kwargs):
        self.middleware.send_event(name, event_type, **kwargs)

    @accepts()
    def ping(self):
        """
        Utility method which just returns "pong".
        Can be used to keep connection/authtoken alive instead of using
        "ping" protocol message.
        """
        return 'pong'

    def _ping_host(self, host, timeout):
        if osc.IS_LINUX:
            process = run(['ping', '-4', '-w', f'{timeout}', host])
        else:
            process = run(['ping', '-t', f'{timeout}', host])

        return process.returncode == 0

    def _ping6_host(self, host, timeout):
        if osc.IS_LINUX:
            process = run(['ping6', '-w', f'{timeout}', host])
        else:
            process = run(['ping6', '-X', f'{timeout}', host])

        return process.returncode == 0

    @accepts(
        Dict(
            'options',
            Str('type', enum=['ICMP', 'ICMPV4', 'ICMPV6'], default='ICMP'),
            Str('hostname', required=True),
            Int('timeout', validators=[Range(min=1, max=60)], default=4),
        ), )
    def ping_remote(self, options):
        """
        Method that will send an ICMP echo request to "hostname"
        and will wait up to "timeout" for a reply.
        """
        ip = None
        ip_found = True
        verrors = ValidationErrors()
        try:
            ip = IpAddress()
            ip(options['hostname'])
            ip = options['hostname']
        except ValueError:
            ip_found = False
        if not ip_found:
            try:
                if options['type'] == 'ICMP':
                    ip = socket.getaddrinfo(options['hostname'], None)[0][4][0]
                elif options['type'] == 'ICMPV4':
                    ip = socket.getaddrinfo(options['hostname'], None,
                                            socket.AF_INET)[0][4][0]
                elif options['type'] == 'ICMPV6':
                    ip = socket.getaddrinfo(options['hostname'], None,
                                            socket.AF_INET6)[0][4][0]
            except socket.gaierror:
                verrors.add(
                    'options.hostname',
                    f'{options["hostname"]} cannot be resolved to an IP address.'
                )

        verrors.check()

        addr = ipaddress.ip_address(ip)
        if not addr.version == 4 and (options['type'] == 'ICMP'
                                      or options['type'] == 'ICMPV4'):
            verrors.add(
                'options.type',
                f'Requested ICMPv4 protocol, but the address provided "{addr}" is not a valid IPv4 address.'
            )
        if not addr.version == 6 and options['type'] == 'ICMPV6':
            verrors.add(
                'options.type',
                f'Requested ICMPv6 protocol, but the address provided "{addr}" is not a valid IPv6 address.'
            )
        verrors.check()

        ping_host = False
        if addr.version == 4:
            ping_host = self._ping_host(ip, options['timeout'])
        elif addr.version == 6:
            ping_host = self._ping6_host(ip, options['timeout'])

        return ping_host

    @accepts(
        Str('method'),
        List('args', default=[]),
        Str('filename'),
    )
    async def download(self, method, args, filename):
        """
        Core helper to call a job marked for download.

        Returns the job id and the URL for download.
        """
        job = await self.middleware.call(
            method, *args, pipes=Pipes(output=self.middleware.pipe()))
        token = await self.middleware.call('auth.generate_token', 300, {
            'filename': filename,
            'job': job.id
        })
        self.middleware.fileapp.register_job(job.id)
        return job.id, f'/_download/{job.id}?auth_token={token}'

    def __kill_multiprocessing(self):
        # We need to kill this because multiprocessing has passed it stderr fd which is /var/log/middlewared.log
        if osc.IS_LINUX:
            for process in psutil.process_iter(attrs=["cmdline"]):
                if "from multiprocessing.resource_tracker import main" in " ".join(
                        process.info["cmdline"]):
                    process.kill()

    @private
    def reconfigure_logging(self):
        """
        When /var/log gets moved because of system dataset
        we need to make sure the log file is reopened because
        of the new location
        """
        reconfigure_logging()
        self.__kill_multiprocessing()

        self.middleware.send_event('core.reconfigure_logging', 'CHANGED')

    @private
    def stop_logging(self):
        stop_logging()
        self.__kill_multiprocessing()

        self.middleware.send_event('core.reconfigure_logging',
                                   'CHANGED',
                                   fields={'stop': True})

    @private
    @accepts(Dict(
        'core-job',
        Int('sleep'),
    ))
    @job()
    def job_test(self, job, data=None):
        """
        Private no-op method to test a job, simply returning `true`.
        """
        if data is None:
            data = {}

        sleep = data.get('sleep')
        if sleep is not None:

            def sleep_fn():
                i = 0
                while i < sleep:
                    job.set_progress((i / sleep) * 100)
                    time.sleep(1)
                    i += 1
                job.set_progress(100)

            t = threading.Thread(target=sleep_fn, daemon=True)
            t.start()
            t.join()
        return True

    @accepts(
        Str('engine', enum=['PTVS', 'PYDEV', 'REMOTE_PDB']),
        Dict(
            'options',
            Str('secret'),
            Str('bind_address', default='0.0.0.0'),
            Int('bind_port', default=3000),
            Str('host'),
            Bool('wait_attach', default=False),
            Str('local_path'),
            Bool('threaded', default=False),
        ),
    )
    async def debug(self, engine, options):
        """
        Setup middlewared for remote debugging.

        engines:
          - PTVS: Python Visual Studio
          - PYDEV: Python Dev (Eclipse/PyCharm)
          - REMOTE_PDB: Remote vanilla PDB (over TCP sockets)

        options:
          - secret: password for PTVS
          - host: required for PYDEV, hostname of local computer (developer workstation)
          - local_path: required for PYDEV, path for middlewared source in local computer (e.g. /home/user/freenas/src/middlewared/middlewared
          - threaded: run debugger in a new thread instead of event loop
        """
        if options['threaded']:
            asyncio.ensure_future(
                self.middleware.run_in_thread(self.__debug, engine, options))
        else:
            self.__debug(engine, options)

    def __debug(self, engine, options):
        if engine == 'PTVS':
            import ptvsd
            if 'secret' not in options:
                raise ValidationError('secret', 'secret is required for PTVS')
            ptvsd.enable_attach(
                options['secret'],
                address=(options['bind_address'], options['bind_port']),
            )
            if options['wait_attach']:
                ptvsd.wait_for_attach()
        elif engine == 'PYDEV':
            for i in ('host', 'local_path'):
                if i not in options:
                    raise ValidationError(i, f'{i} is required for PYDEV')
            os.environ['PATHS_FROM_ECLIPSE_TO_PYTHON'] = json.dumps([
                [
                    options['local_path'],
                    '/usr/local/lib/python3.7/site-packages/middlewared'
                ],
            ])
            import pydevd
            pydevd.stoptrace()
            pydevd.settrace(host=options['host'])
        elif engine == 'REMOTE_PDB':
            from remote_pdb import RemotePdb
            RemotePdb(options['bind_address'],
                      options['bind_port']).set_trace()

    @private
    async def profile(self, method, params=None):
        return await self.middleware.call(method,
                                          *(params or []),
                                          profile=True)

    @private
    def threads_stacks(self):
        return get_threads_stacks()

    @accepts(Str("method"), List("params", default=[]))
    @job(lock=lambda args: f"bulk:{args[0]}")
    async def bulk(self, job, method, params):
        """
        Will loop on a list of items for the given method, returning a list of
        dicts containing a result and error key.

        Result will be the message returned by the method being called,
        or a string of an error, in which case the error key will be the
        exception
        """
        statuses = []
        progress_step = 100 / len(params)
        current_progress = 0

        for p in params:
            try:
                msg = await self.middleware.call(method, *p)
                error = None

                if isinstance(msg, Job):
                    b_job = msg
                    msg = await msg.wait()

                    if b_job.error:
                        error = b_job.error

                statuses.append({"result": msg, "error": error})
            except Exception as e:
                statuses.append({"result": None, "error": str(e)})

            current_progress += progress_step
            job.set_progress(current_progress)

        return statuses

    _environ = {}

    @private
    async def environ(self):
        return self._environ

    @private
    async def environ_update(self, update):
        environ_update(update)

        for k, v in update.items():
            if v is None:
                self._environ.pop(k, None)
            else:
                self._environ[k] = v

        self.middleware.send_event('core.environ', 'CHANGED', fields=update)
示例#19
0
class FilesystemService(Service):
    @accepts(Str('path', required=True), Ref('query-filters'),
             Ref('query-options'))
    def listdir(self, path, filters=None, options=None):
        """
        Get the contents of a directory.

        Each entry of the list consists of:
          name(str): name of the file
          path(str): absolute path of the entry
          realpath(str): absolute real path of the entry (if SYMLINK)
          type(str): DIRECTORY | FILESYSTEM | SYMLINK | OTHER
          size(int): size of the entry
          mode(int): file mode/permission
          uid(int): user id of entry owner
          gid(int): group id of entry onwer
        """
        if not os.path.exists(path):
            raise CallError(f'Directory {path} does not exist', errno.ENOENT)

        if not os.path.isdir(path):
            raise CallError(f'Path {path} is not a directory', errno.ENOTDIR)

        rv = []
        for entry in os.scandir(path):
            if entry.is_dir():
                etype = 'DIRECTORY'
            elif entry.is_file():
                etype = 'FILE'
            elif entry.is_symlink():
                etype = 'SYMLINK'
            else:
                etype = 'OTHER'

            data = {
                'name':
                entry.name,
                'path':
                entry.path,
                'realpath':
                os.path.realpath(entry.path)
                if etype == 'SYMLINK' else entry.path,
                'type':
                etype,
            }
            try:
                stat = entry.stat()
                data.update({
                    'size': stat.st_size,
                    'mode': stat.st_mode,
                    'uid': stat.st_uid,
                    'gid': stat.st_gid,
                })
            except FileNotFoundError:
                data.update({
                    'size': None,
                    'mode': None,
                    'uid': None,
                    'gid': None
                })
            rv.append(data)
        return filter_list(rv, filters=filters or [], options=options or {})

    @accepts(Str('path'))
    def stat(self, path):
        """
        Return the filesystem stat(2) for a given `path`.
        """
        try:
            stat = os.stat(path, follow_symlinks=False)
        except FileNotFoundError:
            raise CallError(f'Path {path} not found', errno.ENOENT)

        stat = {
            'size': stat.st_size,
            'mode': stat.st_mode,
            'uid': stat.st_uid,
            'gid': stat.st_gid,
            'atime': stat.st_atime,
            'mtime': stat.st_mtime,
            'ctime': stat.st_ctime,
            'dev': stat.st_dev,
            'inode': stat.st_ino,
            'nlink': stat.st_nlink,
        }

        if os.path.exists(os.path.join(path, ".windows")):
            stat["acl"] = "windows"
        elif os.path.exists(os.path.join(path, ".mac")):
            stat["acl"] = "mac"
        else:
            stat["acl"] = "unix"

        return stat

    @private
    @accepts(
        Str('path'),
        Str('content'),
        Dict(
            'options',
            Bool('append', default=False),
            Int('mode'),
        ),
    )
    def file_receive(self, path, content, options=None):
        """
        Simplified file receiving method for small files.

        `content` must be a base 64 encoded file content.

        DISCLAIMER: DO NOT USE THIS FOR BIG FILES (> 500KB).
        """
        options = options or {}
        dirname = os.path.dirname(path)
        if not os.path.exists(dirname):
            os.makedirs(dirname)
        if options.get('append'):
            openmode = 'ab'
        else:
            openmode = 'wb+'
        with open(path, openmode) as f:
            f.write(binascii.a2b_base64(content))
        mode = options.get('mode')
        if mode:
            os.chmod(path, mode)
        return True

    @private
    @accepts(
        Str('path'),
        Dict(
            'options',
            Int('offset'),
            Int('maxlen'),
        ),
    )
    def file_get_contents(self, path, options=None):
        """
        Get contents of a file `path` in base64 encode.

        DISCLAIMER: DO NOT USE THIS FOR BIG FILES (> 500KB).
        """
        options = options or {}
        if not os.path.exists(path):
            return None
        with open(path, 'rb') as f:
            if options.get('offset'):
                f.seek(options['offset'])
            data = binascii.b2a_base64(f.read(
                options.get('maxlen'))).decode().strip()
        return data

    @accepts(Str('path'))
    @job(pipes=["output"])
    async def get(self, job, path):
        """
        Job to get contents of `path`.
        """

        if not os.path.isfile(path):
            raise CallError(f'{path} is not a file')

        with open(path, 'rb') as f:
            await self.middleware.run_in_io_thread(shutil.copyfileobj, f,
                                                   job.pipes.output.w)

    @accepts(
        Str('path'),
        Dict(
            'options',
            Bool('append', default=False),
            Int('mode'),
        ),
    )
    @job(pipes=["input"])
    async def put(self, job, path, options=None):
        """
        Job to put contents to `path`.
        """
        options = options or {}
        dirname = os.path.dirname(path)
        if not os.path.exists(dirname):
            os.makedirs(dirname)
        if options.get('append'):
            openmode = 'ab'
        else:
            openmode = 'wb+'

        with open(path, openmode) as f:
            await self.middleware.run_in_io_thread(shutil.copyfileobj,
                                                   job.pipes.input.r, f)

        mode = options.get('mode')
        if mode:
            os.chmod(path, mode)
        return True
示例#20
0
class CredentialsService(CRUDService):
    class Config:
        namespace = "cloudsync.credentials"

        datastore = "system.cloudcredentials"

    @accepts(
        Dict(
            "cloud_sync_credentials_verify",
            Str("provider", required=True),
            Dict("attributes", additional_attrs=True, required=True),
        ))
    async def verify(self, data):
        """
        Verify if `attributes` provided for `provider` are authorized by the `provider`.
        """
        data = dict(data, name="")
        await self._validate("cloud_sync_credentials_create", data)

        async with RcloneConfig({"credentials": data}) as config:
            proc = await run([
                "rclone", "--config", config.config_path, "lsjson", "remote:"
            ],
                             check=False,
                             encoding="utf8")
            if proc.returncode == 0:
                return {"valid": True}
            else:
                return {"valid": False, "error": proc.stderr}

    @accepts(
        Dict(
            "cloud_sync_credentials_create",
            Str("name", required=True),
            Str("provider", required=True),
            Dict("attributes", additional_attrs=True, required=True),
            register=True,
        ))
    async def do_create(self, data):
        """
        Create Cloud Sync Credentials.

        `attributes` is a dictionary of valid values which will be used to authorize with the `provider`.
        """
        await self._validate("cloud_sync_credentials_create", data)

        data["id"] = await self.middleware.call(
            "datastore.insert",
            "system.cloudcredentials",
            data,
        )
        return data

    @accepts(Int("id"),
             Patch("cloud_sync_credentials_create",
                   "cloud_sync_credentials_update", ("attr", {
                       "update": True
                   })))
    async def do_update(self, id, data):
        """
        Update Cloud Sync Credentials of `id`.
        """
        old = await self._get_instance(id)

        new = old.copy()
        new.update(data)

        await self._validate("cloud_sync_credentials_update", new, id)

        await self.middleware.call(
            "datastore.update",
            "system.cloudcredentials",
            id,
            new,
        )

        data["id"] = id

        return data

    @accepts(Int("id"))
    async def do_delete(self, id):
        """
        Delete Cloud Sync Credentials of `id`.
        """
        await self.middleware.call(
            "datastore.delete",
            "system.cloudcredentials",
            id,
        )

    async def _validate(self, schema_name, data, id=None):
        verrors = ValidationErrors()

        await self._ensure_unique(verrors, schema_name, "name", data["name"],
                                  id)

        if data["provider"] not in REMOTES:
            verrors.add(f"{schema_name}.provider", "Invalid provider")
        else:
            provider = REMOTES[data["provider"]]

            attributes_verrors = validate_attributes(
                provider.credentials_schema, data)
            verrors.add_child(f"{schema_name}.attributes", attributes_verrors)

        if verrors:
            raise verrors
示例#21
0
class SMBService(SystemServiceService):
    class Config:
        service = 'cifs'
        service_verb = 'restart'
        datastore = 'services.cifs'
        datastore_extend = 'smb.smb_extend'
        datastore_prefix = 'cifs_srv_'

    @private
    async def smb_extend(self, smb):
        """Extend smb for netbios."""
        smb['netbiosname_local'] = smb['netbiosname']
        if not await self.middleware.call('system.is_freenas'
                                          ) and await self.middleware.call(
                                              'failover.node') == 'B':
            smb['netbiosname_local'] = smb['netbiosname_b']

        smb['netbiosalias'] = (smb['netbiosalias'] or '').split()

        smb['loglevel'] = LOGLEVEL_MAP.get(smb['loglevel'])

        return smb

    async def __validate_netbios_name(self, name):
        return RE_NETBIOSNAME.match(name)

    async def unixcharset_choices(self):
        return await self.generate_choices([
            'UTF-8', 'ISO-8859-1', 'ISO-8859-15', 'GB2312', 'EUC-JP', 'ASCII'
        ])

    @private
    async def generate_choices(self, initial):
        def key_cp(encoding):
            cp = re.compile(
                r"(?P<name>CP|GB|ISO-8859-|UTF-)(?P<num>\d+)").match(encoding)
            if cp:
                return tuple((cp.group('name'), int(cp.group('num'), 10)))
            else:
                return tuple((encoding, float('inf')))

        charset = await self.common_charset_choices()
        return {
            v: v
            for v in
            [c for c in sorted(charset, key=key_cp) if c not in initial] +
            initial
        }

    @accepts()
    async def bindip_choices(self):
        """
        List of valid choices for IP addresses to which to bind the SMB service.
        Addresses assigned by DHCP are excluded from the results.
        """
        choices = {}
        for i in await self.middleware.call('interface.ip_in_use'):
            choices[i['address']] = i['address']
        return choices

    @accepts()
    async def domain_choices(self):
        """
        List of domains visible to winbindd. Returns empty list if winbindd is
        stopped.
        """
        domains = []
        wb = await run([SMBCmd.WBINFO.value, '-m'], check=False)
        if wb.returncode == 0:
            domains = wb.stdout.decode().splitlines()

        return domains

    @private
    async def common_charset_choices(self):
        def check_codec(encoding):
            try:
                return encoding.upper() if codecs.lookup(encoding) else False
            except LookupError:
                return False

        proc = await Popen(['/usr/bin/iconv', '-l'],
                           stdout=subprocess.PIPE,
                           stderr=subprocess.PIPE)
        output = (await proc.communicate())[0].decode()

        encodings = set()
        for line in output.splitlines():
            enc = [e for e in line.split() if check_codec(e)]

            if enc:
                cp = enc[0]
                for e in enc:
                    if e in ('UTF-8', 'ASCII', 'GB2312', 'HZ-GB-2312',
                             'CP1361'):
                        cp = e
                        break

                encodings.add(cp)

        return encodings

    @private
    async def store_ldap_admin_password(self):
        """
        This is required if the LDAP directory service is enabled. The ldap admin dn and
        password are stored in private/secrets.tdb file.
        """
        ldap = await self.middleware.call('datastore.config',
                                          'directoryservice.ldap')
        if not ldap['ldap_enable']:
            return True

        set_pass = await run(
            ['usr/local/bin/smbpasswd', '-w', ldap['ldap_bindpw']],
            check=False)
        if set_pass.returncode != 0:
            self.logger.debug(
                f"Failed to set set ldap bindpw in secrets.tdb: {set_pass.stdout.decode()}"
            )
            return False

        return True

    @private
    def getparm(self, parm, section):
        """
        Get a parameter from the smb4.conf file. This is more reliable than
        'testparm --parameter-name'. testparm will fail in a variety of
        conditions without returning the parameter's value.
        """
        try:
            if section.upper() == 'GLOBAL':
                try:
                    LP_CTX.load(SMBPath.GLOBALCONF.platform())
                except Exception as e:
                    self.logger.warning("Failed to reload smb.conf: %s", e)

                return LP_CTX.get(parm)
            else:
                return self.middleware.call_sync('sharing.smb.reg_getparm',
                                                 section, parm)

        except Exception as e:
            raise CallError(
                f'Attempt to query smb4.conf parameter [{parm}] failed with error: {e}'
            )

    @private
    async def get_next_rid(self):
        next_rid = (await self.config())['next_rid']
        if next_rid == 0:
            try:
                private_dir = await self.middleware.call(
                    "smb.getparm", "private directory", "GLOBAL")
                next_rid = passdb.PDB(
                    f"tdbsam:{private_dir}/passdb.tdb").new_rid()
            except Exception:
                self.logger.warning(
                    "Failed to initialize RID counter from passdb. "
                    "Using default value for initialization.",
                    exc_info=True)
                next_rid = 5000

        await self.middleware.call('datastore.update', 'services.cifs', 1,
                                   {'next_rid': next_rid + 1},
                                   {'prefix': 'cifs_srv_'})
        return next_rid

    @private
    async def setup_directories(self):
        for p in SMBPath:
            if p == SMBPath.STATEDIR:
                path = await self.middleware.call("smb.getparm",
                                                  "state directory", "global")
            elif p == SMBPath.PRIVATEDIR:
                path = await self.middleware.call("smb.getparm", "privatedir",
                                                  "global")
            else:
                path = p.platform()

            try:
                if not await self.middleware.call('filesystem.acl_is_trivial',
                                                  path):
                    self.logger.warning(
                        "Inappropriate ACL detected on path [%s] stripping ACL",
                        path)
                    stripacl = await run(['setfacl', '-b', path], check=False)
                    if stripacl.returncode != 0:
                        self.logger.warning(
                            "Failed to strip ACL from path %s: %s", path,
                            stripacl.stderr.decode())
            except CallError:
                # Currently only time CallError is raise here is on ENOENT, which may be expected
                pass

            if not os.path.exists(path):
                if p.is_dir():
                    os.mkdir(path, p.mode())
            else:
                os.chmod(path, p.mode())

    @private
    @job(lock="smb_configure")
    async def configure(self, job):
        job.set_progress(0, 'Preparing to configure SMB.')
        data = await self.config()
        job.set_progress(10, 'Generating SMB config.')
        await self.middleware.call('etc.generate', 'smb')

        # Following hack will be removed once we make our own samba package
        if osc.IS_LINUX:
            os.remove("/etc/samba/smb.conf")
            os.symlink("/etc/smb4.conf", "etc/samba/smb.conf")

        job.set_progress(20, 'Setting up SMB directories.')
        await self.setup_directories()
        job.set_progress(30, 'Setting up server SID.')
        await self.middleware.call('smb.set_sid', data['cifs_SID'])

        if await self.middleware.call("smb.getparm", "passdb backend",
                                      "global") == "tdbsam":
            job.set_progress(40, 'Synchronizing passdb and groupmap.')
            pdb_job = await self.middleware.call("smb.synchronize_passdb")
            grp_job = await self.middleware.call(
                "smb.synchronize_group_mappings")
            await pdb_job.wait()
            await grp_job.wait()
            await self.middleware.call("admonitor.start")
            job.set_progress(60, 'generating SMB share configuration.')
            await self.middleware.call("etc.generate", "smb_share")

        job.set_progress(70, 'Checking SMB server status.')
        if await self.middleware.call("service.started", "cifs"):
            job.set_progress(80, 'Restarting SMB service.')
            await self.middleware.call("service.restart", "cifs")
        job.set_progress(100, 'Finished configuring SMB.')

    @private
    async def get_smb_ha_mode(self):
        if await self.middleware.call('cache.has_key', 'SMB_HA_MODE'):
            return await self.middleware.call('cache.get', 'SMB_HA_MODE')

        if not await self.middleware.call('system.is_freenas'
                                          ) and await self.middleware.call(
                                              'failover.licensed'):
            system_dataset = await self.middleware.call('systemdataset.config')
            if system_dataset['pool'] != await self.middleware.call(
                    'boot.pool_name'):
                hamode = SMBHAMODE['UNIFIED'].name
            else:
                hamode = SMBHAMODE['LEGACY'].name
        else:
            hamode = SMBHAMODE['STANDALONE'].name

        await self.middleware.call('cache.put', 'SMB_HA_MODE', hamode)
        return hamode

    @private
    async def reset_smb_ha_mode(self):
        await self.middleware.call('cache.pop', 'SMB_HA_MODE')
        return await self.get_smb_ha_mode()

    @accepts(
        Dict(
            'smb_update',
            Str('netbiosname', max_length=15),
            Str('netbiosname_b', max_length=15),
            List('netbiosalias',
                 default=[],
                 items=[Str('netbios_alias', max_length=15)]),
            Str('workgroup'),
            Str('description'),
            Bool('enable_smb1'),
            Str('unixcharset'),
            Str('loglevel',
                enum=['NONE', 'MINIMUM', 'NORMAL', 'FULL', 'DEBUG']),
            Bool('syslog'),
            Bool('aapl_extensions'),
            Bool('localmaster'),
            Str('guest'),
            Str('admin_group', required=False, default=None, null=True),
            Str('filemask'),
            Str('dirmask'),
            Bool('ntlmv1_auth'),
            List('bindip', items=[IPAddr('ip')], default=[]),
            Str('smb_options', max_length=None),
            update=True,
        ))
    async def do_update(self, data):
        """
        Update SMB Service Configuration.

        `netbiosname` defaults to the original hostname of the system.

        `workgroup` and `netbiosname` should have different values.

        `enable_smb1` allows legacy SMB clients to connect to the server when enabled.

        `localmaster` when set, determines if the system participates in a browser election.

        `domain_logons` is used to provide netlogin service for older Windows clients if enabled.

        `guest` attribute is specified to select the account to be used for guest access. It defaults to "nobody".

        `nullpw` when enabled allows the users to authorize access without a password.

        `hostlookup` when enabled, allows using hostnames rather then IP addresses in "hostsallow"/"hostsdeny" fields
        of SMB Shares.
        """
        old = await self.config()

        new = old.copy()
        new.update(data)

        verrors = ValidationErrors()

        if data.get('unixcharset') and data[
                'unixcharset'] not in await self.unixcharset_choices():
            verrors.add('smb_update.unixcharset',
                        'Please provide a valid value for unixcharset')

        for i in ('workgroup', 'netbiosname', 'netbiosname_b', 'netbiosalias'):
            if i not in data or not data[i]:
                continue
            if i == 'netbiosalias':
                for idx, item in enumerate(data[i]):
                    if not await self.__validate_netbios_name(item):
                        verrors.add(f'smb_update.{i}.{idx}',
                                    f'Invalid NetBIOS name: {item}')
            else:
                if not await self.__validate_netbios_name(data[i]):
                    verrors.add(f'smb_update.{i}',
                                f'Invalid NetBIOS name: {data[i]}')

        if new['netbiosname'] and new['netbiosname'].lower(
        ) == new['workgroup'].lower():
            verrors.add('smb_update.netbiosname',
                        'NetBIOS and Workgroup must be unique')

        if data.get('bindip'):
            bindip_choices = list((await self.bindip_choices()).keys())
            for idx, item in enumerate(data['bindip']):
                if item not in bindip_choices:
                    verrors.add(
                        f'smb_update.bindip.{idx}',
                        f'IP address [{item}] is not a configured address for this server'
                    )

        for i in ('filemask', 'dirmask'):
            if i not in data or not data[i]:
                continue
            try:
                if int(data[i], 8) & ~0o11777:
                    raise ValueError('Not an octet')
            except (ValueError, TypeError):
                verrors.add(f'smb_update.{i}', 'Not a valid mask')

        if new['admin_group'] and new['admin_group'] != old['admin_group']:
            await self.middleware.call('smb.add_admin_group',
                                       new['admin_group'])

        if verrors:
            raise verrors

        # TODO: consider using bidict
        for k, v in LOGLEVEL_MAP.items():
            if new['loglevel'] == v:
                new['loglevel'] = k
                break

        await self.compress(new)

        await self._update_service(old, new)
        await self.reset_smb_ha_mode()

        return await self.config()

    @private
    async def compress(self, data):
        data['netbiosalias'] = ' '.join(data['netbiosalias'])
        data.pop('netbiosname_local', None)
        data.pop('next_rid')
        return data
示例#22
0
class CloudSyncService(CRUDService):

    local_fs_lock_manager = FsLockManager()
    remote_fs_lock_manager = FsLockManager()

    class Config:
        datastore = "tasks.cloudsync"
        datastore_extend = "cloudsync._extend"

    @filterable
    async def query(self, filters=None, options=None):
        """
        Query all Cloud Sync Tasks with `query-filters` and `query-options`.
        """
        tasks_or_task = await super().query(filters, options)

        jobs = {}
        for j in await self.middleware.call(
                "core.get_jobs", [("method", "=", "cloudsync.sync")],
            {"order_by": ["id"]}):
            try:
                task_id = int(j["arguments"][0])
            except (IndexError, ValueError):
                continue

            if task_id in jobs and jobs[task_id]["state"] == "RUNNING":
                continue

            jobs[task_id] = j

        if isinstance(tasks_or_task, list):
            for task in tasks_or_task:
                task["job"] = jobs.get(task["id"])
        else:
            tasks_or_task["job"] = jobs.get(tasks_or_task["id"])

        return tasks_or_task

    @private
    async def _extend(self, cloud_sync):
        cloud_sync["credentials"] = cloud_sync.pop("credential")

        cloud_sync["encryption_password"] = await self.middleware.call(
            "pwenc.decrypt", cloud_sync["encryption_password"])
        cloud_sync["encryption_salt"] = await self.middleware.call(
            "pwenc.decrypt", cloud_sync["encryption_salt"])

        Cron.convert_db_format_to_schedule(cloud_sync)

        return cloud_sync

    @private
    async def _compress(self, cloud_sync):
        cloud_sync["credential"] = cloud_sync.pop("credentials")

        cloud_sync["encryption_password"] = await self.middleware.call(
            "pwenc.encrypt", cloud_sync["encryption_password"])
        cloud_sync["encryption_salt"] = await self.middleware.call(
            "pwenc.encrypt", cloud_sync["encryption_salt"])

        Cron.convert_schedule_to_db_format(cloud_sync)

        cloud_sync.pop('job', None)

        return cloud_sync

    @private
    async def _get_credentials(self, credentials_id):
        try:
            return await self.middleware.call("datastore.query",
                                              "system.cloudcredentials",
                                              [("id", "=", credentials_id)],
                                              {"get": True})
        except IndexError:
            return None

    @private
    async def _basic_validate(self, verrors, name, data):
        if data["encryption"]:
            if not data["encryption_password"]:
                verrors.add(
                    f"{name}.encryption_password",
                    "This field is required when encryption is enabled")

        credentials = await self._get_credentials(data["credentials"])
        if not credentials:
            verrors.add(f"{name}.credentials", "Invalid credentials")

        try:
            shlex.split(data["args"])
        except ValueError as e:
            verrors.add(f"{name}.args", f"Parse error: {e.args[0]}")

        if verrors:
            raise verrors

        provider = REMOTES[credentials["provider"]]

        schema = []

        if provider.buckets:
            schema.append(Str("bucket", required=True, empty=False))

        schema.append(Str("folder", required=True))

        schema.extend(provider.task_schema)

        schema.extend(self.common_task_schema(provider))

        attributes_verrors = validate_attributes(schema,
                                                 data,
                                                 additional_attrs=True)

        if not attributes_verrors:
            await provider.pre_save_task(data, credentials, verrors)

        verrors.add_child(f"{name}.attributes", attributes_verrors)

    @private
    async def _validate(self, verrors, name, data):
        await self._basic_validate(verrors, name, data)

        for i, (limit1,
                limit2) in enumerate(zip(data["bwlimit"],
                                         data["bwlimit"][1:])):
            if limit1["time"] >= limit2["time"]:
                verrors.add(
                    f"{name}.bwlimit.{i + 1}.time",
                    f"Invalid time order: {limit1['time']}, {limit2['time']}")

        if data["snapshot"]:
            if data["direction"] != "PUSH":
                verrors.add(f"{name}.snapshot",
                            "This option can only be enabled for PUSH tasks")

    @private
    async def _validate_folder(self, verrors, name, data):
        if data["direction"] == "PULL":
            folder = data["attributes"]["folder"].rstrip("/")
            if folder:
                folder_parent = os.path.normpath(os.path.join(folder, ".."))
                if folder_parent == ".":
                    folder_parent = ""
                folder_basename = os.path.basename(folder)
                ls = await self.list_directory(
                    dict(
                        credentials=data["credentials"],
                        encryption=data["encryption"],
                        filename_encryption=data["filename_encryption"],
                        encryption_password=data["encryption_password"],
                        encryption_salt=data["encryption_salt"],
                        attributes=dict(data["attributes"],
                                        folder=folder_parent),
                        args=data["args"],
                    ))
                for item in ls:
                    if item["Name"] == folder_basename:
                        if not item["IsDir"]:
                            verrors.add(f"{name}.attributes.folder",
                                        "This is not a directory")
                        break
                else:
                    verrors.add(f"{name}.attributes.folder",
                                "Directory does not exist")

        if data["direction"] == "PUSH":
            credentials = await self._get_credentials(data["credentials"])

            provider = REMOTES[credentials["provider"]]

            if provider.readonly:
                verrors.add(f"{name}.direction", "This remote is read-only")

    @accepts(
        Dict(
            "cloud_sync_create",
            Str("description", default=""),
            Str("direction", enum=["PUSH", "PULL"], required=True),
            Str("transfer_mode", enum=["SYNC", "COPY", "MOVE"], required=True),
            Str("path", required=True),
            Int("credentials", required=True),
            Bool("encryption", default=False),
            Bool("filename_encryption", default=False),
            Str("encryption_password", default=""),
            Str("encryption_salt", default=""),
            Cron("schedule", defaults={"minute": "00"}, required=True),
            Bool("follow_symlinks", default=False),
            Int("transfers",
                null=True,
                default=None,
                validators=[Range(min=1)]),
            List("bwlimit",
                 default=[],
                 items=[
                     Dict(
                         "cloud_sync_bwlimit", Str("time",
                                                   validators=[Time()]),
                         Int("bandwidth", validators=[Range(min=1)],
                             null=True))
                 ]),
            List("exclude", default=[], items=[Str("path", empty=False)]),
            Dict("attributes", additional_attrs=True, required=True),
            Bool("snapshot", default=False),
            Str("pre_script", default=""),
            Str("post_script", default=""),
            Str("args", default=""),
            Bool("enabled", default=True),
            register=True,
        ))
    async def do_create(self, cloud_sync):
        """
        Creates a new cloud_sync entry.

        .. examples(websocket)::

          Create a new cloud_sync using amazon s3 attributes, which is supposed to run every hour.

            :::javascript
            {
              "id": "6841f242-840a-11e6-a437-00e04d680384",
              "msg": "method",
              "method": "cloudsync.create",
              "params": [{
                "description": "s3 sync",
                "path": "/mnt/tank",
                "credentials": 1,
                "minute": "00",
                "hour": "*",
                "daymonth": "*",
                "month": "*",
                "attributes": {
                  "bucket": "mybucket",
                  "folder": ""
                },
                "enabled": true
              }]
            }
        """

        verrors = ValidationErrors()

        await self._validate(verrors, "cloud_sync", cloud_sync)

        if verrors:
            raise verrors

        await self._validate_folder(verrors, "cloud_sync", cloud_sync)

        if verrors:
            raise verrors

        cloud_sync = await self._compress(cloud_sync)

        cloud_sync["id"] = await self.middleware.call("datastore.insert",
                                                      "tasks.cloudsync",
                                                      cloud_sync)
        await self.middleware.call("service.restart", "cron")

        cloud_sync = await self._extend(cloud_sync)
        return cloud_sync

    @accepts(Int("id"),
             Patch("cloud_sync_create", "cloud_sync_update", ("attr", {
                 "update": True
             })))
    async def do_update(self, id, data):
        """
        Updates the cloud_sync entry `id` with `data`.
        """
        cloud_sync = await self._get_instance(id)

        # credentials is a foreign key for now
        if cloud_sync["credentials"]:
            cloud_sync["credentials"] = cloud_sync["credentials"]["id"]

        cloud_sync.update(data)

        verrors = ValidationErrors()

        await self._validate(verrors, "cloud_sync_update", cloud_sync)

        if verrors:
            raise verrors

        await self._validate_folder(verrors, "cloud_sync_update", cloud_sync)

        if verrors:
            raise verrors

        cloud_sync = await self._compress(cloud_sync)

        await self.middleware.call("datastore.update", "tasks.cloudsync", id,
                                   cloud_sync)
        await self.middleware.call("service.restart", "cron")

        cloud_sync = await self._extend(cloud_sync)
        return cloud_sync

    @accepts(Int("id"))
    async def do_delete(self, id):
        """
        Deletes cloud_sync entry `id`.
        """
        await self.middleware.call("datastore.delete", "tasks.cloudsync", id)
        await self.middleware.call("alert.oneshot_delete",
                                   "CloudSyncTaskFailed", id)
        await self.middleware.call("service.restart", "cron")

    @accepts(Int("credentials_id"))
    async def list_buckets(self, credentials_id):
        credentials = await self._get_credentials(credentials_id)
        if not credentials:
            raise CallError("Invalid credentials")

        provider = REMOTES[credentials["provider"]]

        if not provider.buckets:
            raise CallError("This provider does not use buckets")

        return await self.ls({"credentials": credentials}, "")

    @accepts(
        Dict(
            "cloud_sync_ls",
            Int("credentials", required=True),
            Bool("encryption", default=False),
            Bool("filename_encryption", default=False),
            Str("encryption_password", default=""),
            Str("encryption_salt", default=""),
            Dict("attributes", required=True, additional_attrs=True),
            Str("args", default=""),
        ))
    async def list_directory(self, cloud_sync):
        """
        List contents of a remote bucket / directory.

        If remote supports buckets, path is constructed by two keys "bucket"/"folder" in `attributes`.
        If remote does not support buckets, path is constructed using "folder" key only in `attributes`.
        "folder" is directory name and "bucket" is bucket name for remote.

        Path examples:

        S3 Service
        `bucketname/directory/name`

        Dropbox Service
        `directory/name`


        `credentials` is a valid id of a Cloud Sync Credential which will be used to connect to the provider.
        """
        verrors = ValidationErrors()

        await self._basic_validate(verrors, "cloud_sync", dict(cloud_sync))

        if verrors:
            raise verrors

        credentials = await self._get_credentials(cloud_sync["credentials"])

        path = get_remote_path(REMOTES[credentials["provider"]],
                               cloud_sync["attributes"])

        return await self.ls(dict(cloud_sync, credentials=credentials), path)

    @private
    async def ls(self, config, path):
        async with RcloneConfig(config) as config:
            proc = await run([
                "rclone", "--config", config.config_path, "lsjson",
                "remote:" + path
            ],
                             check=False,
                             encoding="utf8")
            if proc.returncode == 0:
                return json.loads(proc.stdout)
            else:
                raise CallError(proc.stderr)

    @item_method
    @accepts(Int("id"))
    @job(lock=lambda args: "cloud_sync:{}".format(args[-1]),
         lock_queue_size=1,
         logs=True)
    async def sync(self, job, id):
        """
        Run the cloud_sync job `id`, syncing the local data to remote.
        """

        cloud_sync = await self._get_instance(id)

        credentials = cloud_sync["credentials"]

        local_path = cloud_sync["path"]
        local_direction = FsLockDirection.READ if cloud_sync[
            "direction"] == "PUSH" else FsLockDirection.WRITE

        remote_path = get_remote_path(REMOTES[credentials["provider"]],
                                      cloud_sync["attributes"])
        remote_direction = FsLockDirection.READ if cloud_sync[
            "direction"] == "PULL" else FsLockDirection.WRITE

        directions = {
            FsLockDirection.READ: "reading",
            FsLockDirection.WRITE: "writing",
        }

        job.set_progress(
            0,
            f"Locking local path {local_path!r} for {directions[local_direction]}"
        )
        async with self.local_fs_lock_manager.lock(local_path,
                                                   local_direction):
            job.set_progress(
                0,
                f"Locking remote path {remote_path!r} for {directions[remote_direction]}"
            )
            async with self.remote_fs_lock_manager.lock(
                    f"{credentials['id']}/{remote_path}", remote_direction):
                job.set_progress(0, "Starting")
                try:
                    await rclone(self.middleware, job, cloud_sync)
                    await self.middleware.call("alert.oneshot_delete",
                                               "CloudSyncTaskFailed",
                                               cloud_sync["id"])
                except Exception:
                    await self.middleware.call(
                        "alert.oneshot_create", "CloudSyncTaskFailed", {
                            "id": cloud_sync["id"],
                            "name": cloud_sync["description"],
                        })
                    raise

    @item_method
    @accepts(Int("id"))
    async def abort(self, id):
        """
        Aborts cloud sync task.
        """

        cloud_sync = await self._get_instance(id)

        if cloud_sync["job"] is None:
            return False

        if cloud_sync["job"]["state"] not in ["WAITING", "RUNNING"]:
            return False

        await self.middleware.call("core.job_abort", cloud_sync["job"]["id"])
        return True

    @accepts()
    async def providers(self):
        """
        Returns a list of dictionaries of supported providers for Cloud Sync Tasks.

        `credentials_schema` is JSON schema for credentials attributes.

        `task_schema` is JSON schema for task attributes.

        `buckets` is a boolean value which is set to "true" if provider supports buckets.

        Example of a single provider:

        [
            {
                "name": "AMAZON_CLOUD_DRIVE",
                "title": "Amazon Cloud Drive",
                "credentials_schema": [
                    {
                        "property": "client_id",
                        "schema": {
                            "title": "Amazon Application Client ID",
                            "_required_": true,
                            "type": "string"
                        }
                    },
                    {
                        "property": "client_secret",
                        "schema": {
                            "title": "Application Key",
                            "_required_": true,
                            "type": "string"
                        }
                    }
                ],
                "credentials_oauth": null,
                "buckets": false,
                "bucket_title": "Bucket",
                "task_schema": []
            }
        ]
        """
        return sorted([{
            "name":
            provider.name,
            "title":
            provider.title,
            "credentials_schema": [{
                "property": field.name,
                "schema": field.to_json_schema()
            } for field in provider.credentials_schema],
            "credentials_oauth":
            f"{OAUTH_URL}/{provider.name.lower()}"
            if provider.credentials_oauth else None,
            "buckets":
            provider.buckets,
            "bucket_title":
            provider.bucket_title,
            "task_schema": [{
                "property": field.name,
                "schema": field.to_json_schema()
            } for field in provider.task_schema +
                            self.common_task_schema(provider)],
        } for provider in REMOTES.values()],
                      key=lambda provider: provider["title"].lower())

    def common_task_schema(self, provider):
        schema = []

        if provider.fast_list:
            schema.append(
                Bool("fast_list",
                     default=False,
                     title="Use --fast-list",
                     description=textwrap.dedent("""\
                Use fewer transactions in exchange for more RAM. This may also speed up or slow down your
                transfer. See [rclone documentation](https://rclone.org/docs/#fast-list) for more details.
            """).rstrip()))

        return schema
示例#23
0
class PoolService(Service):
    @item_method
    @accepts(Int('id'),
             Dict(
                 'options',
                 Dict(
                     'geli',
                     Str('passphrase', private=True, default=''),
                 ),
             ))
    @job(lock='pool_expand')
    async def expand(self, job, id, options):
        """
        Expand pool to fit all available disk space.
        """
        pool = await self.middleware.call('pool.get_instance', id)
        if osc.IS_LINUX:
            if options.get('passphrase'):
                raise CallError(
                    'Passphrase should not be supplied for this platform.')
            # FIXME: We have issues in ZoL where when pool is created with partition uuids, we are unable
            #  to expand pool where all pool related options error out saying I/O error
            #  https://github.com/zfsonlinux/zfs/issues/9830
            raise CallError(
                'Expand is not supported on this platform yet because of underlying ZFS issues.'
            )
        else:
            if pool['encrypt']:
                if not pool['is_decrypted']:
                    raise CallError('You can only expand decrypted pool')

                for error in (await self.middleware.call(
                        'pool.pool_lock_pre_check', pool,
                        options['geli']['passphrase'])).errors:
                    raise CallError(error.errmsg)

        all_partitions = {
            p['name']: p
            for p in await self.middleware.call('disk.list_all_partitions')
        }

        try:
            if osc.IS_FREEBSD:
                sysctl.filter('kern.geom.debugflags')[0].value = 16
            geli_resize = []
            try:
                for vdev in sum(pool['topology'].values(), []):
                    if vdev['type'] != 'DISK':
                        logger.debug('Not expanding vdev of type %r',
                                     vdev['type'])
                        continue

                    if vdev['status'] != 'ONLINE':
                        logger.debug('Not expanding vdev that is %r',
                                     vdev['status'])
                        continue

                    part_data = all_partitions.get(vdev['device'])
                    if not part_data:
                        logger.debug('Unable to find partition data for %s',
                                     vdev['device'])

                    partition_number = part_data['partition_number']
                    if not partition_number:
                        logger.debug(
                            'Could not parse partition number from %r',
                            vdev['device'])
                        continue

                    assert part_data['disk'] == vdev['disk']

                    if osc.IS_LINUX:
                        await run(
                            'sgdisk', '-d', str(partition_number), '-n',
                            f'{partition_number}:0:0', '-c', '2:', '-u',
                            f'{partition_number}:{part_data["partition_uuid"]}',
                            '-t', f'{partition_number}:BF01',
                            part_data['path'])
                        await run('partprobe',
                                  os.path.join('/dev', part_data['disk']))
                    else:
                        await run('camcontrol', 'reprobe', vdev['disk'])
                        await run('gpart', 'recover', vdev['disk'])
                        await run('gpart', 'resize', '-i',
                                  str(partition_number), vdev['disk'])

                    if osc.IS_FREEBSD and pool['encrypt']:
                        geli_resize_cmd = ('geli', 'resize', '-s',
                                           str(part_data['size']),
                                           vdev['device'])
                        rollback_cmd = ('gpart', 'resize', '-i',
                                        str(partition_number), '-s',
                                        str(part_data['size']), vdev['disk'])

                        logger.warning(
                            'It will be obligatory to notify GELI that the provider has been resized: %r',
                            join_commandline(geli_resize_cmd))
                        logger.warning('Or to resize provider back: %r',
                                       join_commandline(rollback_cmd))
                        geli_resize.append((geli_resize_cmd, rollback_cmd))
            finally:
                if osc.IS_FREEBSD and geli_resize:
                    await self.__geli_resize(pool, geli_resize, options)
        finally:
            if osc.IS_FREEBSD:
                sysctl.filter('kern.geom.debugflags')[0].value = 0

        for vdev in sum(pool['topology'].values(), []):
            if vdev['type'] != 'DISK' or vdev['status'] != 'ONLINE':
                continue

            await self.middleware.call('zfs.pool.online', pool['name'],
                                       vdev['guid'], True)

    async def __geli_resize(self, pool, geli_resize, options):
        failed_rollback = []

        lock_job = await self.middleware.call('pool.lock', pool['id'],
                                              options['geli']['passphrase'])
        await lock_job.wait()
        if lock_job.error:
            logger.warning('Error locking pool: %s', lock_job.error)

            for geli_resize_cmd, rollback_cmd in geli_resize:
                if not await self.__run_rollback_cmd(rollback_cmd):
                    failed_rollback.append(rollback_cmd)

            if failed_rollback:
                raise CallError(
                    'Locking your encrypted pool failed and rolling back changes failed too. '
                    f'You\'ll need to run the following commands manually:\n%s'
                    % '\n'.join(map(join_commandline, failed_rollback)))
        else:
            for geli_resize_cmd, rollback_cmd in geli_resize:
                try:
                    await run(*geli_resize_cmd,
                              encoding='utf-8',
                              errors='ignore')
                except subprocess.CalledProcessError as geli_resize_error:
                    if geli_resize_error.stderr.strip(
                    ) == 'geli: Size hasn\'t changed.':
                        logger.info('%s: %s',
                                    join_commandline(geli_resize_cmd),
                                    geli_resize_error.stderr.strip())
                    else:
                        logger.error('%r failed: %s. Resizing partition back',
                                     join_commandline(geli_resize_cmd),
                                     geli_resize_error.stderr.strip())
                        if not await self.__run_rollback_cmd(rollback_cmd):
                            failed_rollback.append(rollback_cmd)

            if failed_rollback:
                raise CallError(
                    'Resizing partitions of your encrypted pool failed and rolling back '
                    'changes failed too. You\'ll need to run the following commands manually:\n%s'
                    % '\n'.join(map(join_commandline, failed_rollback)))

            if options['geli']['passphrase']:
                unlock_job = await self.middleware.call(
                    'pool.unlock', pool['id'],
                    {'passphrase': options['geli']['passphrase']})
            else:
                unlock_job = await self.middleware.call(
                    'pool.unlock',
                    pool['id'], {'recoverykey': True},
                    pipes=Pipes(input=self.middleware.pipe()))

                def copy():
                    with open(pool['encryptkey_path'], 'rb') as f:
                        shutil.copyfileobj(f, unlock_job.pipes.input.w)

                try:
                    await self.middleware.run_in_thread(copy)
                finally:
                    await self.middleware.run_in_thread(
                        unlock_job.pipes.input.w.close)

            await unlock_job.wait()
            if unlock_job.error:
                raise CallError(unlock_job.error)

    @staticmethod
    async def __run_rollback_cmd(rollback_cmd):
        try:
            await run(*rollback_cmd, encoding='utf-8', errors='ignore')
        except subprocess.CalledProcessError as rollback_error:
            logger.critical(
                '%r failed: %s. To restore your pool functionality you will have to run this command manually.',
                join_commandline(rollback_cmd), rollback_error.stderr.strip())
            return False
        else:
            return True
示例#24
0
class ServiceService(CRUDService):

    SERVICE_DEFS = {
        's3':
        ServiceDefinition('minio', '/var/run/minio.pid'),
        'ssh':
        ServiceDefinition('sshd', '/var/run/sshd.pid'),
        'rsync':
        ServiceDefinition('rsync', '/var/run/rsyncd.pid'),
        'nfs':
        ServiceDefinition('nfsd', None),
        'afp':
        ServiceDefinition('netatalk', None),
        'cifs':
        ServiceDefinition('smbd', '/var/run/samba4/smbd.pid'),
        'dynamicdns':
        ServiceDefinition('inadyn', None),
        'snmp':
        ServiceDefinition('snmpd', '/var/run/net_snmpd.pid'),
        'ftp':
        ServiceDefinition('proftpd', '/var/run/proftpd.pid'),
        'tftp':
        ServiceDefinition('inetd', '/var/run/inetd.pid'),
        'iscsitarget':
        ServiceDefinition('ctld', '/var/run/ctld.pid'),
        'lldp':
        ServiceDefinition('ladvd', '/var/run/ladvd.pid'),
        'ups':
        ServiceDefinition('upsd', '/var/db/nut/upsd.pid'),
        'upsmon':
        ServiceDefinition('upsmon', '/var/db/nut/upsmon.pid'),
        'smartd':
        ServiceDefinition('smartd', 'smartd-daemon',
                          '/var/run/smartd-daemon.pid'),
        'webshell':
        ServiceDefinition(None, '/var/run/webshell.pid'),
        'webdav':
        ServiceDefinition('httpd', '/var/run/httpd.pid'),
        'netdata':
        ServiceDefinition('netdata', '/var/db/netdata/netdata.pid')
    }

    @filterable
    async def query(self, filters=None, options=None):
        if options is None:
            options = {}
        options['prefix'] = 'srv_'

        services = await self.middleware.call('datastore.query',
                                              'services.services', filters,
                                              options)

        # In case a single service has been requested
        if not isinstance(services, list):
            services = [services]

        jobs = {
            asyncio.ensure_future(self._get_status(entry)): entry
            for entry in services
        }
        await asyncio.wait(list(jobs.keys()), timeout=15)

        def result(task):
            """
            Method to handle results of the greenlets.
            In case a greenlet has timed out, provide UNKNOWN state
            """
            try:
                result = task.result()
            except Exception:
                result = None
                self.logger.warn('Failed to get status', exc_info=True)
            if result is None:
                entry = jobs.get(task)
                entry['state'] = 'UNKNOWN'
                entry['pids'] = []
                return entry
            else:
                return result

        services = list(map(result, jobs))
        return filter_list(services, filters, options)

    @accepts(
        Int('id'),
        Dict(
            'service-update',
            Bool('enable', default=False),
        ),
    )
    async def do_update(self, id, data):
        """
        Update service entry of `id`.

        Currently it only accepts `enable` option which means whether the
        service should start on boot.

        """
        return await self.middleware.call('datastore.update',
                                          'services.services', id,
                                          {'srv_enable': data['enable']})

    @accepts(
        Str('service'),
        Dict(
            'service-control',
            Bool('onetime', default=True),
            register=True,
        ),
    )
    async def start(self, service, options=None):
        """ Start the service specified by `service`.

        The helper will use method self._start_[service]() to start the service.
        If the method does not exist, it would fallback using service(8)."""
        await self.middleware.call_hook('service.pre_start', service)
        sn = self._started_notify("start", service)
        await self._simplecmd("start", service, options)
        return await self.started(service, sn)

    async def started(self, service, sn=None):
        """
        Test if service specified by `service` has been started.
        """
        if sn:
            await self.middleware.run_in_thread(sn.join)

        try:
            svc = await self.query([('service', '=', service)], {'get': True})
            self.middleware.send_event('service.query', 'CHANGED', fields=svc)
            return svc['state'] == 'RUNNING'
        except IndexError:
            f = getattr(self, '_started_' + service, None)
            if callable(f):
                if inspect.iscoroutinefunction(f):
                    return (await f())[0]
                else:
                    return f()[0]
            else:
                return (await self._started(service))[0]

    @accepts(
        Str('service'),
        Ref('service-control'),
    )
    async def stop(self, service, options=None):
        """ Stop the service specified by `service`.

        The helper will use method self._stop_[service]() to stop the service.
        If the method does not exist, it would fallback using service(8)."""
        await self.middleware.call_hook('service.pre_stop', service)
        sn = self._started_notify("stop", service)
        await self._simplecmd("stop", service, options)
        return await self.started(service, sn)

    @accepts(
        Str('service'),
        Ref('service-control'),
    )
    async def restart(self, service, options=None):
        """
        Restart the service specified by `service`.

        The helper will use method self._restart_[service]() to restart the service.
        If the method does not exist, it would fallback using service(8)."""
        await self.middleware.call_hook('service.pre_restart', service)
        sn = self._started_notify("restart", service)
        await self._simplecmd("restart", service, options)
        return await self.started(service, sn)

    @accepts(
        Str('service'),
        Ref('service-control'),
    )
    async def reload(self, service, options=None):
        """
        Reload the service specified by `service`.

        The helper will use method self._reload_[service]() to reload the service.
        If the method does not exist, the helper will try self.restart of the
        service instead."""
        await self.middleware.call_hook('service.pre_reload', service)
        try:
            await self._simplecmd("reload", service, options)
        except Exception as e:
            await self.restart(service, options)
        return await self.started(service)

    async def _get_status(self, service):
        f = getattr(self, '_started_' + service['service'], None)
        if callable(f):
            if inspect.iscoroutinefunction(f):
                running, pids = await f()
            else:
                running, pids = f()
        else:
            running, pids = await self._started(service['service'])

        if running:
            state = 'RUNNING'
        else:
            if service['enable']:
                state = 'CRASHED'
            else:
                state = 'STOPPED'

        service['state'] = state
        service['pids'] = pids
        return service

    async def _simplecmd(self, action, what, options=None):
        self.logger.debug("Calling: %s(%s) ", action, what)
        f = getattr(self, '_' + action + '_' + what, None)
        if f is None:
            # Provide generic start/stop/restart verbs for rc.d scripts
            if what in self.SERVICE_DEFS:
                if self.SERVICE_DEFS[what].rc_script:
                    what = self.SERVICE_DEFS[what].rc_script
            if action in ("start", "stop", "restart", "reload"):
                if action == 'restart':
                    await self._system("/usr/sbin/service " + what +
                                       " forcestop ")
                await self._service(what, action, **options)
            else:
                raise ValueError("Internal error: Unknown command")
        else:
            call = f(**(options or {}))
            if inspect.iscoroutinefunction(f):
                await call

    async def _system(self, cmd, options=None):
        stdout = DEVNULL
        if options and 'stdout' in options:
            stdout = options['stdout']
        stderr = DEVNULL
        if options and 'stderr' in options:
            stderr = options['stderr']

        proc = await Popen(cmd,
                           stdout=stdout,
                           stderr=stderr,
                           shell=True,
                           close_fds=True)
        await proc.communicate()
        return proc.returncode

    async def _service(self, service, verb, **options):
        onetime = options.pop('onetime', None)
        force = options.pop('force', None)
        quiet = options.pop('quiet', None)
        extra = options.pop('extra', '')

        # force comes before one which comes before quiet
        # they are mutually exclusive
        preverb = ''
        if force:
            preverb = 'force'
        elif onetime:
            preverb = 'one'
        elif quiet:
            preverb = 'quiet'

        return await self._system(
            '/usr/sbin/service {} {}{} {}'.format(
                service,
                preverb,
                verb,
                extra,
            ), options)

    def _started_notify(self, verb, what):
        """
        The check for started [or not] processes is currently done in 2 steps
        This is the first step which involves a thread StartNotify that watch for event
        before actually start/stop rc.d scripts

        Returns:
            StartNotify object if the service is known or None otherwise
        """

        if what in self.SERVICE_DEFS:
            sn = StartNotify(verb=verb,
                             pidfile=self.SERVICE_DEFS[what].pidfile)
            sn.start()
            return sn
        else:
            return None

    async def _started(self, what, notify=None):
        """
        This is the second step::
        Wait for the StartNotify thread to finish and then check for the
        status of pidfile/procname using pgrep

        Returns:
            True whether the service is alive, False otherwise
        """

        if what in self.SERVICE_DEFS:
            if notify:
                await self.middleware.run_in_thread(notify.join)

            if self.SERVICE_DEFS[what].pidfile:
                pgrep = "/bin/pgrep -F {}{}".format(
                    self.SERVICE_DEFS[what].pidfile,
                    ' ' + self.SERVICE_DEFS[what].procname
                    if self.SERVICE_DEFS[what].procname else '',
                )
            else:
                pgrep = "/bin/pgrep {}".format(
                    self.SERVICE_DEFS[what].procname)
            proc = await Popen(pgrep,
                               shell=True,
                               stdout=PIPE,
                               stderr=PIPE,
                               close_fds=True)
            data = (await proc.communicate())[0].decode()

            if proc.returncode == 0:
                return True, [
                    int(i) for i in data.strip().split('\n') if i.isdigit()
                ]
        return False, []

    async def _start_webdav(self, **kwargs):
        await self._service("ix-apache", "start", force=True, **kwargs)
        await self._service("apache24", "start", **kwargs)

    async def _stop_webdav(self, **kwargs):
        await self._service("apache24", "stop", **kwargs)

    async def _restart_webdav(self, **kwargs):
        await self._service("apache24", "stop", force=True, **kwargs)
        await self._service("ix-apache", "start", force=True, **kwargs)
        await self._service("apache24", "restart", **kwargs)

    async def _reload_webdav(self, **kwargs):
        await self._service("ix-apache", "start", force=True, **kwargs)
        await self._service("apache24", "reload", **kwargs)

    async def _restart_django(self, **kwargs):
        await self._service("django", "restart", **kwargs)

    async def _start_webshell(self, **kwargs):
        await self._system(
            "/usr/local/bin/python /usr/local/www/freenasUI/tools/webshell.py")

    async def _restart_webshell(self, **kwargs):
        try:
            with open('/var/run/webshell.pid', 'r') as f:
                pid = f.read()
                os.kill(int(pid), signal.SIGTERM)
                time.sleep(0.2)
                os.kill(int(pid), signal.SIGKILL)
        except:
            pass
        await self._system(
            "ulimit -n 1024 && /usr/local/bin/python /usr/local/www/freenasUI/tools/webshell.py"
        )

    async def _restart_iscsitarget(self, **kwargs):
        await self._service("ix-ctld", "start", force=True, **kwargs)
        await self._service("ctld", "stop", force=True, **kwargs)
        await self._service("ix-ctld", "start", quiet=True, **kwargs)
        await self._service("ctld", "restart", **kwargs)

    async def _start_iscsitarget(self, **kwargs):
        await self._service("ix-ctld", "start", quiet=True, **kwargs)
        await self._service("ctld", "start", **kwargs)

    async def _stop_iscsitarget(self, **kwargs):
        await self._service("ix-ctld", "stop", force=True, **kwargs)
        await self._service("ctld", "stop", force=True, **kwargs)

    async def _reload_iscsitarget(self, **kwargs):
        await self._service("ix-ctld", "start", quiet=True, **kwargs)
        await self._service("ctld", "reload", **kwargs)

    async def _start_collectd(self, **kwargs):
        await self._service("ix-collectd", "start", quiet=True, **kwargs)
        await self._service("collectd", "restart", **kwargs)

    async def _restart_collectd(self, **kwargs):
        await self._service("collectd", "stop", **kwargs)
        await self._service("ix-collectd", "start", quiet=True, **kwargs)
        await self._service("collectd", "start", **kwargs)

    async def _start_sysctl(self, **kwargs):
        await self._service("sysctl", "start", **kwargs)
        await self._service("ix-sysctl", "start", quiet=True, **kwargs)

    async def _reload_sysctl(self, **kwargs):
        await self._service("sysctl", "start", **kwargs)
        await self._service("ix-sysctl", "reload", **kwargs)

    async def _start_network(self, **kwargs):
        await self.middleware.call('interfaces.sync')
        await self.middleware.call('routes.sync')

    async def _stop_jails(self, **kwargs):
        for jail in await self.middleware.call('datastore.query',
                                               'jails.jails'):
            try:
                await self.middleware.call('notifier.warden', 'stop', [],
                                           {'jail': jail['jail_host']})
            except Exception as e:
                self.logger.debug(f'Failed to stop jail {jail["jail_host"]}',
                                  exc_info=True)

    async def _start_jails(self, **kwargs):
        await self._service("ix-warden", "start", **kwargs)
        for jail in await self.middleware.call('datastore.query',
                                               'jails.jails'):
            if jail['jail_autostart']:
                try:
                    await self.middleware.call('notifier.warden', 'start', [],
                                               {'jail': jail['jail_host']})
                except Exception as e:
                    self.logger.debug(
                        f'Failed to start jail {jail["jail_host"]}',
                        exc_info=True)
        await self._service("ix-plugins", "start", **kwargs)
        await self.reload("http", kwargs)

    async def _restart_jails(self, **kwargs):
        await self._stop_jails()
        await self._start_jails()

    async def _stop_pbid(self, **kwargs):
        await self._service("pbid", "stop", **kwargs)

    async def _start_pbid(self, **kwargs):
        await self._service("pbid", "start", **kwargs)

    async def _restart_pbid(self, **kwargs):
        await self._service("pbid", "restart", **kwargs)

    async def _reload_named(self, **kwargs):
        await self._service("named", "reload", **kwargs)

    async def _reload_hostname(self, **kwargs):
        await self._system('/bin/hostname ""')
        await self._service("ix-hostname", "start", quiet=True, **kwargs)
        await self._service("hostname", "start", quiet=True, **kwargs)
        await self._service("mdnsd", "restart", quiet=True, **kwargs)
        await self._service("collectd", "stop", **kwargs)
        await self._service("ix-collectd", "start", quiet=True, **kwargs)
        await self._service("collectd", "start", **kwargs)

    async def _reload_resolvconf(self, **kwargs):
        await self._reload_hostname()
        await self._service("ix-resolv", "start", quiet=True, **kwargs)

    async def _reload_networkgeneral(self, **kwargs):
        await self._reload_resolvconf()
        await self._service("routing", "restart", **kwargs)

    async def _reload_timeservices(self, **kwargs):
        await self._service("ix-localtime", "start", quiet=True, **kwargs)
        await self._service("ix-ntpd", "start", quiet=True, **kwargs)
        await self._service("ntpd", "restart", **kwargs)
        os.environ['TZ'] = await self.middleware.call('datastore.query',
                                                      'system.settings', [], {
                                                          'order_by': ['-id'],
                                                          'get': True
                                                      })['stg_timezone']
        time.tzset()

    async def _restart_smartd(self, **kwargs):
        await self._service("ix-smartd", "start", quiet=True, **kwargs)
        await self._service("smartd-daemon", "stop", force=True, **kwargs)
        await self._service("smartd-daemon", "restart", **kwargs)

    async def _reload_ssh(self, **kwargs):
        await self._service("ix-sshd", "start", quiet=True, **kwargs)
        await self._service("ix_register", "reload", **kwargs)
        await self._service("openssh", "reload", **kwargs)
        await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs)

    async def _start_ssh(self, **kwargs):
        await self._service("ix-sshd", "start", quiet=True, **kwargs)
        await self._service("ix_register", "reload", **kwargs)
        await self._service("openssh", "start", **kwargs)
        await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs)

    async def _stop_ssh(self, **kwargs):
        await self._service("openssh", "stop", force=True, **kwargs)
        await self._service("ix_register", "reload", **kwargs)

    async def _restart_ssh(self, **kwargs):
        await self._service("ix-sshd", "start", quiet=True, **kwargs)
        await self._service("openssh", "stop", force=True, **kwargs)
        await self._service("ix_register", "reload", **kwargs)
        await self._service("openssh", "restart", **kwargs)
        await self._service("ix_sshd_save_keys", "start", quiet=True, **kwargs)

    async def _start_ssl(self, what=None):
        if what is not None:
            await self._service("ix-ssl", "start", quiet=True, extra=what)
        else:
            await self._service("ix-ssl", "start", quiet=True)

    async def _start_s3(self, **kwargs):
        await self.middleware.call('etc.generate', 's3')
        await self._service("minio",
                            "start",
                            quiet=True,
                            stdout=None,
                            stderr=None,
                            **kwargs)

    async def _reload_s3(self, **kwargs):
        await self.middleware.call('etc.generate', 's3')
        await self._service("minio",
                            "restart",
                            quiet=True,
                            stdout=None,
                            stderr=None,
                            **kwargs)

    async def _reload_rsync(self, **kwargs):
        await self._service("ix-rsyncd", "start", quiet=True, **kwargs)
        await self._service("rsyncd", "restart", **kwargs)

    async def _restart_rsync(self, **kwargs):
        await self._stop_rsync()
        await self._start_rsync()

    async def _start_rsync(self, **kwargs):
        await self._service("ix-rsyncd", "start", quiet=True, **kwargs)
        await self._service("rsyncd", "start", **kwargs)

    async def _stop_rsync(self, **kwargs):
        await self._service("rsyncd", "stop", force=True, **kwargs)

    async def _started_nis(self, **kwargs):
        res = False
        if not await self._system("/etc/directoryservice/NIS/ctl status"):
            res = True
        return res, []

    async def _start_nis(self, **kwargs):
        res = False
        if not await self._system("/etc/directoryservice/NIS/ctl start"):
            res = True
        return res

    async def _restart_nis(self, **kwargs):
        res = False
        if not await self._system("/etc/directoryservice/NIS/ctl restart"):
            res = True
        return res

    async def _stop_nis(self, **kwargs):
        res = False
        if not await self._system("/etc/directoryservice/NIS/ctl stop"):
            res = True
        return res

    async def _started_ldap(self, **kwargs):
        if (await self._system('/usr/sbin/service ix-ldap status') != 0):
            return False, []
        return await self.middleware.call('notifier.ldap_status'), []

    async def _start_ldap(self, **kwargs):
        res = False
        if not await self._system("/etc/directoryservice/LDAP/ctl start"):
            res = True
        return res

    async def _stop_ldap(self, **kwargs):
        res = False
        if not await self._system("/etc/directoryservice/LDAP/ctl stop"):
            res = True
        return res

    async def _restart_ldap(self, **kwargs):
        res = False
        if not await self._system("/etc/directoryservice/LDAP/ctl restart"):
            res = True
        return res

    async def _start_lldp(self, **kwargs):
        await self._service("ladvd", "start", **kwargs)

    async def _stop_lldp(self, **kwargs):
        await self._service("ladvd", "stop", force=True, **kwargs)

    async def _restart_lldp(self, **kwargs):
        await self._service("ladvd", "stop", force=True, **kwargs)
        await self._service("ladvd", "restart", **kwargs)

    async def _clear_activedirectory_config(self):
        await self._system(
            "/bin/rm -f /etc/directoryservice/ActiveDirectory/config")

    async def _started_activedirectory(self, **kwargs):
        for srv in (
                'kinit',
                'activedirectory',
        ):
            if await self._system('/usr/sbin/service ix-%s status' %
                                  (srv, )) != 0:
                return False, []
        if await self._system('/usr/local/bin/wbinfo -p') != 0:
            return False, []
        if await self._system('/usr/local/bin/wbinfo -t') != 0:
            return False, []
        return True, []

    async def _start_activedirectory(self, **kwargs):
        res = False
        if not await self._system(
                "/etc/directoryservice/ActiveDirectory/ctl start"):
            res = True
        return res

    async def _stop_activedirectory(self, **kwargs):
        res = False
        if not await self._system(
                "/etc/directoryservice/ActiveDirectory/ctl stop"):
            res = True
        return res

    async def _restart_activedirectory(self, **kwargs):
        res = False
        if not await self._system(
                "/etc/directoryservice/ActiveDirectory/ctl restart"):
            res = True
        return res

    async def _started_domaincontroller(self, **kwargs):
        res = False
        if not await self._system(
                "/etc/directoryservice/DomainController/ctl status"):
            res = True
        return res, []

    async def _start_domaincontroller(self, **kwargs):
        res = False
        if not await self._system(
                "/etc/directoryservice/DomainController/ctl start"):
            res = True
        return res

    async def _stop_domaincontroller(self, **kwargs):
        res = False
        if not await self._system(
                "/etc/directoryservice/DomainController/ctl stop"):
            res = True
        return res

    async def _restart_domaincontroller(self, **kwargs):
        res = False
        if not await self._system(
                "/etc/directoryservice/DomainController/ctl restart"):
            res = True
        return res

    async def _restart_syslogd(self, **kwargs):
        await self._service("ix-syslogd", "start", quiet=True, **kwargs)
        await self._system("/etc/local/rc.d/syslog-ng restart")

    async def _start_syslogd(self, **kwargs):
        await self._service("ix-syslogd", "start", quiet=True, **kwargs)
        await self._system("/etc/local/rc.d/syslog-ng start")

    async def _stop_syslogd(self, **kwargs):
        await self._system("/etc/local/rc.d/syslog-ng stop")

    async def _reload_syslogd(self, **kwargs):
        await self._service("ix-syslogd", "start", quiet=True, **kwargs)
        await self._system("/etc/local/rc.d/syslog-ng reload")

    async def _start_tftp(self, **kwargs):
        await self._service("ix-inetd", "start", quiet=True, **kwargs)
        await self._service("inetd", "start", **kwargs)

    async def _reload_tftp(self, **kwargs):
        await self._service("ix-inetd", "start", quiet=True, **kwargs)
        await self._service("inetd", "stop", force=True, **kwargs)
        await self._service("inetd", "restart", **kwargs)

    async def _restart_tftp(self, **kwargs):
        await self._service("ix-inetd", "start", quiet=True, **kwargs)
        await self._service("inetd", "stop", force=True, **kwargs)
        await self._service("inetd", "restart", **kwargs)

    async def _restart_cron(self, **kwargs):
        await self._service("ix-crontab", "start", quiet=True, **kwargs)

    async def _start_motd(self, **kwargs):
        await self._service("ix-motd", "start", quiet=True, **kwargs)
        await self._service("motd", "start", quiet=True, **kwargs)

    async def _start_ttys(self, **kwargs):
        await self._service("ix-ttys", "start", quiet=True, **kwargs)

    async def _reload_ftp(self, **kwargs):
        await self._service("ix-proftpd", "start", quiet=True, **kwargs)
        await self._service("proftpd", "restart", **kwargs)

    async def _restart_ftp(self, **kwargs):
        await self._stop_ftp()
        await self._start_ftp()

    async def _start_ftp(self, **kwargs):
        await self._service("ix-proftpd", "start", quiet=True, **kwargs)
        await self._service("proftpd", "start", **kwargs)

    async def _stop_ftp(self, **kwargs):
        await self._service("proftpd", "stop", force=True, **kwargs)

    async def _start_ups(self, **kwargs):
        await self._service("ix-ups", "start", quiet=True, **kwargs)
        await self._service("nut", "start", **kwargs)
        await self._service("nut_upsmon", "start", **kwargs)
        await self._service("nut_upslog", "start", **kwargs)

    async def _stop_ups(self, **kwargs):
        await self._service("nut_upslog", "stop", force=True, **kwargs)
        await self._service("nut_upsmon", "stop", force=True, **kwargs)
        await self._service("nut", "stop", force=True, **kwargs)

    async def _restart_ups(self, **kwargs):
        await self._service("ix-ups", "start", quiet=True, **kwargs)
        await self._service("nut", "stop", force=True, **kwargs)
        await self._service("nut_upsmon", "stop", force=True, **kwargs)
        await self._service("nut_upslog", "stop", force=True, **kwargs)
        await self._service("nut", "restart", **kwargs)
        await self._service("nut_upsmon", "restart", **kwargs)
        await self._service("nut_upslog", "restart", **kwargs)

    async def _started_ups(self, **kwargs):
        mode = (await self.middleware.call('datastore.query', 'services.ups',
                                           [], {
                                               'order_by': ['-id'],
                                               'get': True
                                           }))['ups_mode']
        if mode == "master":
            svc = "ups"
        else:
            svc = "upsmon"
        return await self._started(svc)

    async def _start_afp(self, **kwargs):
        await self._service("ix-afpd", "start", **kwargs)
        await self._service("netatalk", "start", **kwargs)

    async def _stop_afp(self, **kwargs):
        await self._service("netatalk", "stop", force=True, **kwargs)
        # when netatalk stops if afpd or cnid_metad is stuck
        # they'll get left behind, which can cause issues
        # restarting netatalk.
        await self._system("pkill -9 afpd")
        await self._system("pkill -9 cnid_metad")

    async def _restart_afp(self, **kwargs):
        await self._stop_afp()
        await self._start_afp()

    async def _reload_afp(self, **kwargs):
        await self._service("ix-afpd", "start", quiet=True, **kwargs)
        await self._system("killall -1 netatalk")

    async def _reload_nfs(self, **kwargs):
        await self._service("ix-nfsd", "start", quiet=True, **kwargs)

    async def _restart_nfs(self, **kwargs):
        await self._stop_nfs(**kwargs)
        await self._start_nfs(**kwargs)

    async def _stop_nfs(self, **kwargs):
        await self._service("lockd", "stop", force=True, **kwargs)
        await self._service("statd", "stop", force=True, **kwargs)
        await self._service("nfsd", "stop", force=True, **kwargs)
        await self._service("mountd", "stop", force=True, **kwargs)
        await self._service("nfsuserd", "stop", force=True, **kwargs)
        await self._service("gssd", "stop", force=True, **kwargs)
        await self._service("rpcbind", "stop", force=True, **kwargs)
        if not await self.middleware.call('system.is_freenas'):
            await self._service("vaaiserver", "stop", force=True, **kwargs)

    async def _start_nfs(self, **kwargs):
        nfs = await self.middleware.call('datastore.config', 'services.nfs')
        await self._service("ix-nfsd", "start", quiet=True, **kwargs)
        await self._service("rpcbind", "start", quiet=True, **kwargs)
        await self._service("gssd", "start", quiet=True, **kwargs)
        # Workaround to work with "onetime", since the rc scripts depend on rc flags.
        if nfs['nfs_srv_v4']:
            sysctl.filter('vfs.nfsd.server_max_nfsvers')[0].value = 4
            if nfs['nfs_srv_v4_v3owner']:
                sysctl.filter('vfs.nfsd.enable_stringtouid')[0].value = 1
                await self._service("nfsuserd", "stop", force=True, **kwargs)
            else:
                sysctl.filter('vfs.nfsd.enable_stringtouid')[0].value = 0
                await self._service("nfsuserd", "start", quiet=True, **kwargs)
        else:
            sysctl.filter('vfs.nfsd.server_max_nfsvers')[0].value = 3
            if nfs['nfs_srv_16']:
                await self._service("nfsuserd", "start", quiet=True, **kwargs)
        await self._service("mountd", "start", quiet=True, **kwargs)
        await self._service("nfsd", "start", quiet=True, **kwargs)
        await self._service("statd", "start", quiet=True, **kwargs)
        await self._service("lockd", "start", quiet=True, **kwargs)
        if not await self.middleware.call('system.is_freenas'):
            await self._service("vaaiserver", "start", quiet=True, **kwargs)

    async def _force_stop_jail(self, **kwargs):
        await self._service("jail", "stop", force=True, **kwargs)

    async def _start_plugins(self, jail=None, plugin=None, **kwargs):
        if jail and plugin:
            await self._system(
                "/usr/sbin/service ix-plugins forcestart %s:%s" %
                (jail, plugin))
        else:
            await self._service("ix-plugins", "start", force=True, **kwargs)

    async def _stop_plugins(self, jail=None, plugin=None, **kwargs):
        if jail and plugin:
            await self._system("/usr/sbin/service ix-plugins forcestop %s:%s" %
                               (jail, plugin))
        else:
            await self._service("ix-plugins", "stop", force=True, **kwargs)

    async def _restart_plugins(self, jail=None, plugin=None):
        await self._stop_plugins(jail=jail, plugin=plugin)
        await self._start_plugins(jail=jail, plugin=plugin)

    async def _started_plugins(self, jail=None, plugin=None, **kwargs):
        res = False
        if jail and plugin:
            if self._system("/usr/sbin/service ix-plugins status %s:%s" %
                            (jail, plugin)) == 0:
                res = True
        else:
            if await self._service("ix-plugins", "status", **kwargs) == 0:
                res = True
        return res, []

    async def _restart_dynamicdns(self, **kwargs):
        await self._service("ix-inadyn", "start", quiet=True, **kwargs)
        await self._service("inadyn", "stop", force=True, **kwargs)
        await self._service("inadyn", "restart", **kwargs)

    async def _reload_dynamicdns(self, **kwargs):
        await self._service("ix-inadyn", "start", quiet=True, **kwargs)
        await self._service("inadyn", "stop", force=True, **kwargs)
        await self._service("inadyn", "restart", **kwargs)

    async def _restart_system(self, **kwargs):
        asyncio.ensure_future(
            self._system("/bin/sleep 3 && /sbin/shutdown -r now"))

    async def _stop_system(self, **kwargs):
        asyncio.ensure_future(
            self._system("/bin/sleep 3 && /sbin/shutdown -p now"))

    async def _reload_cifs(self, **kwargs):
        await self._service("ix-pre-samba", "start", quiet=True, **kwargs)
        await self._service("samba_server", "reload", force=True, **kwargs)
        await self._service("ix-post-samba", "start", quiet=True, **kwargs)
        await self._service("mdnsd", "restart", **kwargs)
        # After mdns is restarted we need to reload netatalk to have it rereregister
        # with mdns. Ticket #7133
        await self._service("netatalk", "reload", **kwargs)

    async def _restart_cifs(self, **kwargs):
        await self._service("ix-pre-samba", "start", quiet=True, **kwargs)
        await self._service("samba_server", "stop", force=True, **kwargs)
        await self._service("samba_server", "restart", quiet=True, **kwargs)
        await self._service("ix-post-samba", "start", quiet=True, **kwargs)
        await self._service("mdnsd", "restart", **kwargs)
        # After mdns is restarted we need to reload netatalk to have it rereregister
        # with mdns. Ticket #7133
        await self._service("netatalk", "reload", **kwargs)

    async def _start_cifs(self, **kwargs):
        await self._service("ix-pre-samba", "start", quiet=True, **kwargs)
        await self._service("samba_server", "start", quiet=True, **kwargs)
        await self._service("ix-post-samba", "start", quiet=True, **kwargs)

    async def _stop_cifs(self, **kwargs):
        await self._service("samba_server", "stop", force=True, **kwargs)
        await self._service("ix-post-samba", "start", quiet=True, **kwargs)

    async def _start_snmp(self, **kwargs):
        await self._service("ix-snmpd", "start", quiet=True, **kwargs)
        await self._service("snmpd", "start", quiet=True, **kwargs)
        await self._service("snmp-agent", "start", quiet=True, **kwargs)

    async def _stop_snmp(self, **kwargs):
        await self._service("snmp-agent", "stop", quiet=True, **kwargs)
        await self._service("snmpd", "stop", quiet=True, **kwargs)

    async def _restart_snmp(self, **kwargs):
        await self._service("snmp-agent", "stop", quiet=True, **kwargs)
        await self._service("snmpd", "stop", force=True, **kwargs)
        await self._service("ix-snmpd", "start", quiet=True, **kwargs)
        await self._service("snmpd", "start", quiet=True, **kwargs)
        await self._service("snmp-agent", "start", quiet=True, **kwargs)

    async def _reload_snmp(self, **kwargs):
        await self._service("snmp-agent", "stop", quiet=True, **kwargs)
        await self._service("snmpd", "stop", force=True, **kwargs)
        await self._service("ix-snmpd", "start", quiet=True, **kwargs)
        await self._service("snmpd", "start", quiet=True, **kwargs)
        await self._service("snmp-agent", "start", quiet=True, **kwargs)

    async def _restart_http(self, **kwargs):
        await self._service("ix-nginx", "start", quiet=True, **kwargs)
        await self._service("ix_register", "reload", **kwargs)
        await self._service("nginx", "restart", **kwargs)

    async def _reload_http(self, **kwargs):
        await self._service("ix-nginx", "start", quiet=True, **kwargs)
        await self._service("ix_register", "reload", **kwargs)
        await self._service("nginx", "reload", **kwargs)

    async def _reload_loader(self, **kwargs):
        await self._service("ix-loader", "reload", **kwargs)

    async def _start_loader(self, **kwargs):
        await self._service("ix-loader", "start", quiet=True, **kwargs)

    async def __saver_loaded(self):
        pipe = os.popen("kldstat|grep daemon_saver")
        out = pipe.read().strip('\n')
        pipe.close()
        return (len(out) > 0)

    async def _start_saver(self, **kwargs):
        if not self.__saver_loaded():
            await self._system("kldload daemon_saver")

    async def _stop_saver(self, **kwargs):
        if self.__saver_loaded():
            await self._system("kldunload daemon_saver")

    async def _restart_saver(self, **kwargs):
        await self._stop_saver()
        await self._start_saver()

    async def _reload_disk(self, **kwargs):
        await self._service("ix-fstab", "start", quiet=True, **kwargs)
        await self._service("ix-swap", "start", quiet=True, **kwargs)
        await self._service("swap", "start", quiet=True, **kwargs)
        await self._service("mountlate", "start", quiet=True, **kwargs)
        # Restarting collectd may take a long time and there is no
        # benefit in waiting for it since even if it fails it wont
        # tell the user anything useful.
        asyncio.ensure_future(self.restart("collectd", kwargs))

    async def _reload_user(self, **kwargs):
        await self._service("ix-passwd", "start", quiet=True, **kwargs)
        await self._service("ix-aliases", "start", quiet=True, **kwargs)
        await self._service("ix-sudoers", "start", quiet=True, **kwargs)
        await self.reload("cifs", kwargs)

    async def _restart_system_datasets(self, **kwargs):
        systemdataset = await self.middleware.call('systemdataset.setup')
        if not systemdataset:
            return None
        if systemdataset['syslog']:
            await self.restart("syslogd", kwargs)
        await self.restart("cifs", kwargs)
        if systemdataset['rrd']:
            # Restarting collectd may take a long time and there is no
            # benefit in waiting for it since even if it fails it wont
            # tell the user anything useful.
            asyncio.ensure_future(self.restart("collectd", kwargs))
示例#25
0
class CronJobService(CRUDService):
    class Config:
        datastore = 'tasks.cronjob'
        datastore_prefix = 'cron_'
        datastore_extend = 'cronjob.cron_extend'
        namespace = 'cronjob'
        cli_namespace = 'task.cron_job'

    ENTRY = Patch(
        'cron_job_create',
        'cron_job_entry',
        ('add', Int('id')),
    )

    @private
    def cron_extend(self, data):
        Cron.convert_db_format_to_schedule(data)
        return data

    @private
    async def construct_cron_command(self,
                                     schedule,
                                     user,
                                     command,
                                     stdout=True,
                                     stderr=True):
        return list(
            filter(bool, (
                schedule['minute'], schedule['hour'], schedule['dom'],
                schedule['month'], schedule['dow'], user,
                'PATH="/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:/root/bin"',
                command.replace('\n', ''), '> /dev/null' if stdout else '',
                '2> /dev/null' if stderr else '')))

    @private
    async def validate_data(self, data, schema):
        verrors = ValidationErrors()

        user = data.get('user')
        if user:
            # Windows users can have spaces in their usernames
            # http://www.freebsd.org/cgi/query-pr.cgi?pr=164808
            if ' ' in user:
                verrors.add(f'{schema}.user', 'Usernames cannot have spaces')

            else:
                user_data = None
                with contextlib.suppress(KeyError):
                    user_data = await self.middleware.call(
                        'dscache.get_uncached_user', user)

                if not user_data:
                    verrors.add(f'{schema}.user',
                                'Specified user does not exist')

        command = data.get('command')
        if not command:
            verrors.add(f'{schema}.command',
                        'Please specify a command for cronjob task.')

        return verrors, data

    @accepts(
        Dict('cron_job_create',
             Bool('enabled'),
             Bool('stderr', default=False),
             Bool('stdout', default=True),
             Cron('schedule', defaults={'minute': '00'}),
             Str('command', required=True),
             Str('description'),
             Str('user', required=True),
             register=True))
    async def do_create(self, data):
        """
        Create a new cron job.

        `stderr` and `stdout` are boolean values which if `true`, represent that we would like to suppress
        standard error / standard output respectively.

        .. examples(websocket)::

          Create a cron job which executes `touch /tmp/testfile` after every 5 minutes.

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "cronjob.create",
                "params": [{
                    "enabled": true,
                    "schedule": {
                        "minute": "5",
                        "hour": "*",
                        "dom": "*",
                        "month": "*",
                        "dow": "*"
                    },
                    "command": "touch /tmp/testfile",
                    "description": "Test command",
                    "user": "******",
                    "stderr": true,
                    "stdout": true
                }]
            }
        """
        verrors, data = await self.validate_data(data, 'cron_job_create')
        if verrors:
            raise verrors

        Cron.convert_schedule_to_db_format(data)

        data['id'] = await self.middleware.call(
            'datastore.insert', self._config.datastore, data,
            {'prefix': self._config.datastore_prefix})

        await self.middleware.call('service.restart', 'cron')

        return await self._get_instance(data['id'])

    async def do_update(self, id, data):
        """
        Update cronjob of `id`.
        """
        task_data = await self.query(filters=[('id', '=', id)],
                                     options={'get': True})
        original_data = task_data.copy()
        task_data.update(data)
        verrors, task_data = await self.validate_data(task_data,
                                                      'cron_job_update')

        if verrors:
            raise verrors

        Cron.convert_schedule_to_db_format(task_data)
        Cron.convert_schedule_to_db_format(original_data)

        if len(set(task_data.items()) ^ set(original_data.items())) > 0:

            await self.middleware.call(
                'datastore.update', self._config.datastore, id, task_data,
                {'prefix': self._config.datastore_prefix})

            await self.middleware.call('service.restart', 'cron')

        return await self._get_instance(id)

    async def do_delete(self, id):
        """
        Delete cronjob of `id`.
        """
        response = await self.middleware.call('datastore.delete',
                                              self._config.datastore, id)

        await self.middleware.call('service.restart', 'cron')

        return response

    @accepts(
        Int('id'),
        Bool('skip_disabled', default=False),
    )
    @returns()
    @job(lock=lambda args: f'cron_job_run_{args[0]}',
         logs=True,
         lock_queue_size=1)
    def run(self, job, id, skip_disabled):
        """
        Job to run cronjob task of `id`.
        """
        def __cron_log(line):
            job.logs_fd.write(line)
            syslog.syslog(syslog.LOG_INFO, line.decode())

        cron_task = self.middleware.call_sync('cronjob.get_instance', id)
        if skip_disabled and not cron_task['enabled']:
            raise CallError('Cron job is disabled', errno.EINVAL)

        cron_cmd = ' '.join(
            self.middleware.call_sync('cronjob.construct_cron_command',
                                      cron_task['schedule'], cron_task['user'],
                                      cron_task['command'],
                                      cron_task['stdout'],
                                      cron_task['stderr'])[7:])

        job.set_progress(10, 'Executing Cron Task')

        syslog.openlog('cron', facility=syslog.LOG_CRON)

        syslog.syslog(syslog.LOG_INFO,
                      f'({cron_task["user"]}) CMD ({cron_cmd})')

        cp = run_command_with_user_context(cron_cmd, cron_task['user'],
                                           __cron_log)

        syslog.closelog()

        job.set_progress(85, 'Executed Cron Task')

        if cp.stdout:
            email = (self.middleware.call_sync(
                'user.query', [['username', '=', cron_task['user']]],
                {'get': True}))['email']
            stdout = cp.stdout.decode()
            if email:
                mail_job = self.middleware.call_sync('mail.send', {
                    'subject': 'CronTask Run',
                    'text': stdout,
                    'to': [email]
                })

                job.set_progress(95, 'Sending mail for Cron Task output')

                mail_job.wait_sync()
                if mail_job.error:
                    job.logs_fd.write(
                        f'Failed to send email for CronTask run: {mail_job.error}'
                        .encode())
            else:
                job.set_progress(
                    95,
                    'Email for root user not configured. Skipping sending mail.'
                )

            job.logs_fd.write(
                f'Executed CronTask - {cron_cmd}: {stdout}'.encode())

        if cp.returncode:
            raise CallError(
                f'CronTask "{cron_cmd}" exited with {cp.returncode} (non-zero) exit status.'
            )

        job.set_progress(100, 'Execution of Cron Task complete.')
示例#26
0
class S3Service(SystemServiceService):
    class Config:
        service = "s3"
        datastore_prefix = "s3_"
        datastore_extend = "s3.config_extend"
        cli_namespace = "service.s3"

    ENTRY = Dict(
        's3_entry',
        Str('bindip', required=True),
        Int('bindport', validators=[Range(min=1024, max=65535)],
            required=True),
        Int('console_bindport',
            validators=[Range(min=1024, max=65535)],
            required=True),
        Str('access_key', max_length=20, required=True),
        Str('secret_key', max_length=40, required=True),
        Bool('browser', required=True),
        Str('tls_server_uri', null=True, required=True),
        Str('storage_path', required=True),
        Int('certificate', null=True, required=True),
        Int('id', required=True),
    )

    @accepts()
    @returns(Dict('s3_bindip_choices', additional_attrs=True))
    async def bindip_choices(self):
        """
        Return ip choices for S3 service to use.
        """
        return {
            d['address']: d['address']
            for d in await self.middleware.call('interface.ip_in_use', {
                'static': True,
                'any': True
            })
        }

    @private
    async def config_extend(self, s3):
        s3['storage_path'] = s3.pop('disks', None)
        s3.pop('mode', None)
        if s3.get('certificate'):
            s3['certificate'] = s3['certificate']['id']
        return s3

    @accepts(
        Patch(
            's3_entry',
            's3_update',
            ('edit', {
                'name':
                'tls_server_uri',
                'method':
                lambda x: setattr(x, 'validators', [
                    Hostname(explanation='Should be a valid hostname')
                ])
            }),
            ('rm', {
                'name': 'id'
            }),
            ('attr', {
                'update': True
            }),
        ))
    async def do_update(self, data):
        """
        Update S3 Service Configuration.

        `access_key` must only contain alphanumeric characters and should be between 5 and 20 characters.

        `secret_key` must only contain alphanumeric characters and should be between 8 and 40 characters.

        `browser` when set, enables the web user interface for the S3 Service.

        `certificate` is a valid certificate id which exists in the system. This is used to enable secure
        S3 connections.
        """
        old = await self.config()

        new = old.copy()
        new.update(data)

        verrors = ValidationErrors()

        for attr, minlen, maxlen in (
            ('access_key', 5, 20),
            ('secret_key', 8, 40),
        ):
            curlen = len(new.get(attr, ''))
            if curlen < minlen or curlen > maxlen:
                verrors.add(
                    f's3_update.{attr}',
                    f'Attribute should be {minlen} to {maxlen} in length')

        if not new['storage_path'] and await self.middleware.call(
                'service.started', 's3'):
            verrors.add('s3_update.storage_path',
                        'S3 must be stopped before unsetting storage path.')
        elif new['storage_path']:
            await check_path_resides_within_volume(verrors, self.middleware,
                                                   's3_update.storage_path',
                                                   new['storage_path'])

            if not verrors:
                if new['storage_path'].rstrip('/').count('/') < 3:
                    verrors.add(
                        's3_update.storage_path',
                        'Top level datasets are not allowed. i.e /mnt/tank/dataset is allowed'
                    )
                else:
                    # If the storage_path does not exist, let's create it
                    if not os.path.exists(new['storage_path']):
                        os.makedirs(new['storage_path'])

        if new['certificate']:
            verrors.extend(
                (await
                 self.middleware.call('certificate.cert_services_validation',
                                      new['certificate'],
                                      's3_update.certificate', False)))

        if new['certificate'] and not new['tls_server_uri']:
            verrors.add(
                's3_update.tls_server_uri',
                'Please provide a SAN or CN(i.e. Common Name) from the attached certificate.'
            )

        if new['bindip'] not in await self.bindip_choices():
            verrors.add('s3_update.bindip',
                        'Please provide a valid ip address')

        if verrors:
            raise verrors

        new['disks'] = new.pop('storage_path')

        await self._update_service(old, new)

        if new['disks'] and (await self.middleware.call(
                'filesystem.stat', new['disks']))['user'] != 'minio':
            await self.middleware.call(
                'filesystem.setperm', {
                    'path':
                    new['disks'],
                    'mode':
                    str(775),
                    'uid': (await self.middleware.call(
                        'dscache.get_uncached_user', 'minio'))['pw_uid'],
                    'gid': (await self.middleware.call(
                        'dscache.get_uncached_group', 'minio'))['gr_gid'],
                    'options': {
                        'recursive': True,
                        'traverse': False
                    }
                })

        return await self.config()
示例#27
0
class KubernetesService(Service):

    @accepts(
        Str('backup_name'),
        Dict(
            'options',
            Bool('wait_for_csi', default=True),
        )
    )
    @returns()
    @job(lock='kubernetes_restore_backup')
    def restore_backup(self, job, backup_name, options):
        """
        Restore `backup_name` chart releases backup.

        It should be noted that a rollback will be initiated which will destroy any newer snapshots/clones
        of `ix-applications` dataset then the snapshot in question of `backup_name`.
        """
        self.middleware.call_sync('kubernetes.validate_k8s_setup')
        backup = self.middleware.call_sync('kubernetes.list_backups').get(backup_name)
        if not backup:
            raise CallError(f'Backup {backup_name!r} does not exist', errno=errno.ENOENT)

        job.set_progress(5, 'Basic validation complete')

        self.middleware.call_sync('service.stop', 'kubernetes')
        job.set_progress(15, 'Stopped kubernetes')

        shutil.rmtree('/etc/rancher', True)
        db_config = self.middleware.call_sync('datastore.config', 'services.kubernetes')
        self.middleware.call_sync('datastore.update', 'services.kubernetes', db_config['id'], {'cni_config': {}})

        k8s_config = self.middleware.call_sync('kubernetes.config')
        # We will be nuking the docker dataset and re-creating it
        # Motivation behind this action is that docker creates many datasets per image/container and
        # when we re-initialize the k8s cluster, it's possible we are leftover with datasets which are not
        # being used by any container. Images will be pulled again by k8s, so that shouldn't be a concern
        # in this regard.
        docker_ds = os.path.join(k8s_config['dataset'], 'docker')
        self.middleware.call_sync('zfs.dataset.delete', docker_ds, {'force': True, 'recursive': True})

        job.set_progress(20, f'Rolling back {backup["snapshot_name"]}')
        self.middleware.call_sync(
            'zfs.snapshot.rollback', backup['snapshot_name'], {
                'force': True,
                'recursive': True,
                'recursive_clones': True,
                'recursive_rollback': True,
            }
        )
        self.middleware.call_sync('zfs.dataset.create', {'name': docker_ds, 'type': 'FILESYSTEM'})
        self.middleware.call_sync('zfs.dataset.mount', docker_ds)

        # FIXME: Remove this sleep, sometimes the k3s dataset fails to umount
        #  After discussion with mav, it sounds like a bug to him in zfs, so until that is fixed, we have this sleep
        # time.sleep(20)

        k3s_ds_path = os.path.join('/mnt', k8s_config['dataset'], 'k3s')
        # We are not deleting k3s ds simply because of upstream zfs bug (https://github.com/openzfs/zfs/issues/12460)
        # self.middleware.call_sync('zfs.dataset.delete', k3s_ds, {'force': True, 'recursive': True})
        # self.middleware.call_sync('zfs.dataset.create', {'name': k3s_ds, 'type': 'FILESYSTEM'})
        # self.middleware.call_sync('zfs.dataset.mount', k3s_ds)
        for name in os.listdir(k3s_ds_path):
            d_name = os.path.join(k3s_ds_path, name)
            if os.path.isdir(d_name):
                shutil.rmtree(d_name)

        job.set_progress(25, 'Initializing new kubernetes cluster')
        self.middleware.call_sync('service.start', 'kubernetes')

        while True:
            config = self.middleware.call_sync('k8s.node.config')
            if (
                config['node_configured'] and not config['spec']['taints'] and (
                    not options['wait_for_csi'] or self.middleware.call_sync('k8s.csi.config')['csi_ready']
                )
            ):
                break

            time.sleep(5)

        job.set_progress(30, 'Kubernetes cluster re-initialized')

        backup_dir = backup['backup_path']
        releases_datasets = set(
            ds['id'].split('/', 3)[-1].split('/', 1)[0] for ds in self.middleware.call_sync(
                'zfs.dataset.get_instance', f'{k8s_config["dataset"]}/releases'
            )['children']
        )

        releases = os.listdir(backup_dir)
        len_releases = len(releases)
        restored_chart_releases = collections.defaultdict(lambda: {'pv_info': {}})

        for index, release_name in enumerate(releases):
            job.set_progress(
                30 + ((index + 1) / len_releases) * 60,
                f'Restoring helm configuration for {release_name!r} chart release'
            )

            if release_name not in releases_datasets:
                self.logger.error(
                    'Skipping backup of %r chart release due to missing chart release dataset', release_name
                )
                continue

            r_backup_dir = os.path.join(backup_dir, release_name)
            if any(
                not os.path.exists(os.path.join(r_backup_dir, f)) for f in ('namespace.yaml', 'secrets')
            ) or not os.listdir(os.path.join(r_backup_dir, 'secrets')):
                self.logger.error(
                    'Skipping backup of %r chart release due to missing configuration files', release_name
                )
                continue

            # First we will restore namespace and then the secrets
            with open(os.path.join(r_backup_dir, 'namespace.yaml'), 'r') as f:
                namespace_body = yaml.load(f.read(), Loader=yaml.FullLoader)
                self.middleware.call_sync('k8s.namespace.create', {'body': namespace_body})

            secrets_dir = os.path.join(r_backup_dir, 'secrets')
            for secret in sorted(os.listdir(secrets_dir)):
                with open(os.path.join(secrets_dir, secret)) as f:
                    self.middleware.call_sync(
                        'k8s.secret.create', {
                            'namespace': namespace_body['metadata']['name'],
                            'body': yaml.load(f.read(), Loader=yaml.FullLoader),
                        }
                    )

            with open(os.path.join(r_backup_dir, 'workloads_replica_counts.json'), 'r') as f:
                restored_chart_releases[release_name]['replica_counts'] = json.loads(f.read())

            pv_info_path = os.path.join(r_backup_dir, 'pv_info.json')
            if os.path.exists(pv_info_path):
                with open(pv_info_path, 'r') as f:
                    restored_chart_releases[release_name]['pv_info'] = json.loads(f.read())

        # Now helm will recognise the releases as valid, however we don't have any actual k8s deployed resource
        # That will be adjusted with updating chart releases with their existing values and helm will see that
        # k8s resources don't exist and will create them for us
        job.set_progress(92, 'Creating kubernetes resources')
        update_jobs = []
        datasets = set(
            d['id'] for d in self.middleware.call_sync(
                'zfs.dataset.query', [['id', '^', f'{os.path.join(k8s_config["dataset"], "releases")}/']], {
                    'extra': {'retrieve_properties': False}
                }
            )
        )
        for chart_release in restored_chart_releases:
            # Before we have resources created for the chart releases, we will restore PVs if possible and then
            # restore the chart release, so if there is any PVC expecting a PV, it will be able to claim it as soon
            # as it is created. If this is not done in this order, PVC will request a new dataset and we will lose
            # the mapping with the old dataset.
            self.middleware.call_sync(
                'chart.release.create_update_storage_class_for_chart_release',
                chart_release, os.path.join(k8s_config['dataset'], 'releases', chart_release, 'volumes')
            )
            failed_pv_restores = []
            for pvc, pv in restored_chart_releases[chart_release]['pv_info'].items():
                if pv['dataset'] not in datasets:
                    failed_pv_restores.append(f'Unable to locate PV dataset {pv["dataset"]!r} for {pvc!r} PVC.')
                    continue

                zv_details = pv['zv_details']
                try:
                    self.middleware.call_sync('k8s.zv.create', {
                        'metadata': {
                            'name': zv_details['metadata']['name'],
                        },
                        'spec': {
                            'capacity': zv_details['spec']['capacity'],
                            'poolName': zv_details['spec']['poolName'],
                        },
                    })
                except Exception as e:
                    failed_pv_restores.append(f'Unable to create ZFS Volume for {pvc!r} PVC: {e}')
                    continue

                pv_spec = pv['pv_details']['spec']
                try:
                    self.middleware.call_sync('k8s.pv.create', {
                        'metadata': {
                            'name': pv['name'],
                        },
                        'spec': {
                            'capacity': {
                                'storage': pv_spec['capacity']['storage'],
                            },
                            'claimRef': {
                                'name': pv_spec['claim_ref']['name'],
                                'namespace': pv_spec['claim_ref']['namespace'],
                            },
                            'csi': {
                                'volumeAttributes': {
                                    'openebs.io/poolname': pv_spec['csi']['volume_attributes']['openebs.io/poolname']
                                },
                                'volumeHandle': pv_spec['csi']['volume_handle'],
                            },
                            'storageClassName': pv_spec['storage_class_name'],
                        },
                    })
                except Exception as e:
                    failed_pv_restores.append(f'Unable to create PV for {pvc!r} PVC: {e}')

            if failed_pv_restores:
                self.logger.error(
                    'Failed to restore PVC(s) for %r chart release:\n%s', chart_release, '\n'.join(failed_pv_restores)
                )

            update_jobs.append(self.middleware.call_sync('chart.release.update', chart_release, {'values': {}}))

        for update_job in update_jobs:
            update_job.wait_sync()

        # We should have k8s resources created now. Now a new PVC will be created as k8s won't retain the original
        # information which was in it's state at backup time. We will get current dataset mapping and then
        # rename old ones which were mapped to the same PVC to have the new name
        chart_releases = {
            c['name']: c for c in self.middleware.call_sync(
                'chart.release.query', [], {'extra': {'retrieve_resources': True}}
            )
        }

        for release_name in list(restored_chart_releases):
            if release_name not in chart_releases:
                restored_chart_releases.pop(release_name)
            else:
                restored_chart_releases[release_name]['resources'] = chart_releases[release_name]['resources']

        job.set_progress(97, 'Scaling scalable workloads')

        for chart_release in restored_chart_releases.values():
            self.middleware.call_sync(
                'chart.release.scale_release_internal', chart_release['resources'], None,
                chart_release['replica_counts'], True,
            )

        job.set_progress(100, f'Restore of {backup_name!r} backup complete')
示例#28
0
class UpdateService(Service):
    @accepts()
    def get_trains(self):
        """
        Returns available trains dict and the currently configured train as well as the
        train of currently booted environment.
        """
        data = self.middleware.call_sync('datastore.config', 'system.update')
        conf = Configuration.Configuration()
        conf.LoadTrainsConfig()

        selected = None
        trains = {}
        for name, descr in (conf.AvailableTrains() or {}).items():
            train = conf._trains.get(name)
            if train is None:
                train = Train.Train(name, descr)
            if not selected and data['upd_train'] == train.Name():
                selected = data['upd_train']
            trains[train.Name()] = {
                'description': train.Description(),
                'sequence': train.LastSequence(),
            }
        if not data['upd_train'] or not selected:
            selected = conf.CurrentTrain()
        return {
            'trains': trains,
            'current': conf.CurrentTrain(),
            'selected': selected,
        }

    @accepts(
        Dict(
            'update-check-available',
            Str('train', required=False),
            required=False,
        ))
    def check_available(self, attrs=None):
        """
        Checks if there is an update available from update server.

        status:
          - REBOOT_REQUIRED: an update has already been applied
          - AVAILABLE: an update is available
          - UNAVAILABLE: no update available

        .. examples(websocket)::

          Check available update using default train:

            :::javascript
            {
                "id": "6841f242-840a-11e6-a437-00e04d680384",
                "msg": "method",
                "method": "update.check_available"
            }
        """

        try:
            applied = self.middleware.call_sync('cache.get', 'update.applied')
        except Exception:
            applied = False
        if applied is True:
            return {'status': 'REBOOT_REQUIRED'}

        train = (attrs or {}).get('train') or self.middleware.call_sync(
            'update.get_trains')['selected']

        handler = CheckUpdateHandler()
        manifest = CheckForUpdates(
            diff_handler=handler.diff_call,
            handler=handler.call,
            train=train,
        )

        if not manifest:
            return {'status': 'UNAVAILABLE'}

        data = {
            'status': 'AVAILABLE',
            'changes': handler.changes,
            'notice': manifest.Notice(),
            'notes': manifest.Notes(),
        }

        conf = Configuration.Configuration()
        sys_mani = conf.SystemManifest()
        if sys_mani:
            sequence = sys_mani.Sequence()
        else:
            sequence = ''
        data['changelog'] = get_changelog(train,
                                          start=sequence,
                                          end=manifest.Sequence())

        data['version'] = manifest.Version()
        return data

    @accepts(Str('path', null=True, default=None))
    async def get_pending(self, path=None):
        """
        Gets a list of packages already downloaded and ready to be applied.
        Each entry of the lists consists of type of operation and name of it, e.g.

          {
            "operation": "upgrade",
            "name": "baseos-11.0 -> baseos-11.1"
          }
        """
        if path is None:
            path = await self.middleware.call('notifier.get_update_location')
        data = []
        try:
            changes = await self.middleware.run_in_thread(
                Update.PendingUpdatesChanges, path)
        except (
                UpdateIncompleteCacheException,
                UpdateInvalidCacheException,
                UpdateBusyCacheException,
        ):
            changes = []
        if changes:
            if changes.get("Reboot", True) is False:
                for svc in changes.get("Restart", []):
                    data.append({
                        'operation': svc,
                        'name': Update.GetServiceDescription(svc),
                    })
            for new, op, old in changes['Packages']:
                if op == 'upgrade':
                    name = '%s-%s -> %s-%s' % (
                        old.Name(),
                        old.Version(),
                        new.Name(),
                        new.Version(),
                    )
                elif op == 'install':
                    name = '%s-%s' % (new.Name(), new.Version())
                else:
                    # Its unclear why "delete" would feel out new
                    # instead of old, sounds like a pkgtools bug?
                    if old:
                        name = '%s-%s' % (old.Name(), old.Version())
                    else:
                        name = '%s-%s' % (new.Name(), new.Version())

                data.append({
                    'operation': op,
                    'name': name,
                })
        return data

    @accepts(
        Dict(
            'update',
            Str('train', required=False),
            Bool('reboot', default=False),
            required=False,
        ))
    @job(lock='update', process=True)
    async def update(self, job, attrs=None):
        """
        Downloads (if not already in cache) and apply an update.
        """
        attrs = attrs or {}
        train = attrs.get('train') or (
            await self.middleware.call('update.get_trains'))['selected']
        location = await self.middleware.call('notifier.get_update_location')

        job.set_progress(0, 'Retrieving update manifest')

        handler = UpdateHandler(self, job)

        update = Update.DownloadUpdate(
            train,
            location,
            check_handler=handler.check_handler,
            get_handler=handler.get_handler,
        )
        if update is False:
            raise ValueError('No update available')

        new_manifest = Manifest.Manifest(require_signature=True)
        new_manifest.LoadPath('{}/MANIFEST'.format(location))

        Update.ApplyUpdate(
            location,
            install_handler=handler.install_handler,
        )
        await self.middleware.call('cache.put', 'update.applied', True)

        if attrs.get('reboot'):
            await self.middleware.call('system.reboot', {'delay': 10})
        return True

    @accepts()
    @job(lock='updatedownload')
    def download(self, job):
        train = self.middleware.call_sync('update.get_trains')['selected']
        location = self.middleware.call_sync('notifier.get_update_location')

        job.set_progress(0, 'Retrieving update manifest')

        handler = UpdateHandler(self, job, 100)

        Update.DownloadUpdate(
            train,
            location,
            check_handler=handler.check_handler,
            get_handler=handler.get_handler,
        )
        update = Update.CheckForUpdates(train=train, cache_dir=location)

        if not update:
            return False

        notified = False
        try:
            if self.middleware.call_sync('cache.has_key', 'update.notified'):
                notified = self.middleware.call_sync('cache.get',
                                                     'update.notified')
        except Exception:
            pass

        if not notified:
            self.middleware.call_sync('cache.put', 'update.notified', True)
            conf = Configuration.Configuration()
            sys_mani = conf.SystemManifest()
            if sys_mani:
                sequence = sys_mani.Sequence()
            else:
                sequence = ''

            changelog = get_changelog(train,
                                      start=sequence,
                                      end=update.Sequence())

            try:
                # FIXME: Translation
                self.middleware.call_sync(
                    'mail.send', {
                        'subject': 'Update Available',
                        'text':
                        '''A new update is available for the %(train)s train.
Version: %(version)s
Changelog:
%(changelog)s
''' % {
                            'train': train,
                            'version': update.Version(),
                            'changelog': changelog,
                        },
                    }).wait_sync()
            except Exception:
                self.logger.warn('Failed to send email about new update',
                                 exc_info=True)
        return True

    @accepts(Str('path'))
    @job(lock='updatemanual', process=True)
    async def manual(self, job, path):
        """
        Apply manual update of file `path`.
        """
        rv = await self.middleware.call('notifier.validate_update', path)
        if not rv:
            raise CallError('Invalid update file', errno.EINVAL)
        await self.middleware.call('notifier.apply_update', path, timeout=None)
        try:
            await self.middleware.call('notifier.destroy_upload_location')
        except Exception:
            self.logger.warn('Failed to destroy upload location',
                             exc_info=True)

    @accepts(Dict(
        'updatefile',
        Str('destination'),
    ))
    @job(lock='updatemanual', pipes=['input'])
    async def file(self, job, options):
        """
        Updates the system using the uploaded .tar file.

        Use null `destination` to create a temporary location.
        """

        dest = options.get('destination')

        if not dest:
            try:
                await self.middleware.call('notifier.create_upload_location')
                dest = '/var/tmp/firmware'
            except Exception as e:
                raise CallError(str(e))
        elif not dest.startswith('/mnt/'):
            raise CallError('Destination must reside within a pool')

        if not os.path.isdir(dest):
            raise CallError('Destination is not a directory')

        destfile = os.path.join(dest, 'manualupdate.tar')
        dest_extracted = os.path.join(dest, '.update')

        try:
            job.set_progress(10, 'Writing uploaded file to disk')
            with open(destfile, 'wb') as f:
                await self.middleware.run_in_thread(
                    shutil.copyfileobj,
                    job.pipes.input.r,
                    f,
                    1048576,
                )

            def do_update():
                try:
                    job.set_progress(30, 'Extracting uploaded file')
                    ExtractFrozenUpdate(destfile, dest_extracted, verbose=True)
                    job.set_progress(50, 'Applying update')
                    ApplyUpdate(dest_extracted)
                except Exception as e:
                    raise CallError(str(e))

            await self.middleware.run_in_thread(do_update)

            job.set_progress(95, 'Cleaning up')

        finally:
            if os.path.exists(destfile):
                os.unlink(destfile)

            if os.path.exists(dest_extracted):
                shutil.rmtree(dest_extracted, ignore_errors=True)

        if dest == '/var/tmp/firmware':
            await self.middleware.call('notifier.destroy_upload_location')

        job.set_progress(100, 'Update completed')
示例#29
0
class KubernetesService(ConfigService):
    class Config:
        datastore = 'services.kubernetes'
        datastore_extend = 'kubernetes.k8s_extend'
        cli_namespace = 'app.kubernetes'

    ENTRY = Dict(
        'kubernetes_entry',
        Bool('servicelb', required=True),
        Bool('configure_gpus', required=True),
        Str('pool', required=True, null=True),
        IPAddr('cluster_cidr', required=True, cidr=True, empty=True),
        IPAddr('service_cidr', required=True, cidr=True, empty=True),
        IPAddr('cluster_dns_ip', required=True, empty=True),
        IPAddr('node_ip', required=True),
        Str('route_v4_interface', required=True, null=True),
        IPAddr('route_v4_gateway', required=True, null=True, v6=False),
        Str('route_v6_interface', required=True, null=True),
        IPAddr('route_v6_gateway', required=True, null=True, v4=False),
        Str('dataset', required=True, null=True),
        Int('id', required=True),
        update=True,
    )

    @private
    async def k8s_extend(self, data):
        data['dataset'] = applications_ds_name(
            data['pool']) if data['pool'] else None
        data.pop('cni_config')
        return data

    @private
    async def unused_cidrs(self, network_cidrs):
        return [
            str(network) for network in itertools.chain(
                ipaddress.ip_network('172.16.0.0/12', False).subnets(4),
                ipaddress.ip_network('10.0.0.0/8', False).subnets(8),
                ipaddress.ip_network('192.168.0.0/16', False).subnets(1),
            ) if not any(
                network.overlaps(used_network)
                for used_network in network_cidrs)
        ]

    @private
    async def validate_data(self, data, schema, old_data):
        verrors = ValidationErrors()

        if data.pop('migrate_applications', False):
            if data['pool'] == old_data['pool']:
                verrors.add(
                    f'{schema}.migrate_applications',
                    'Migration of applications dataset only happens when a new pool is configured.'
                )
            elif not data['pool']:
                verrors.add(
                    f'{schema}.migrate_applications',
                    'Pool must be specified when migration of ix-application dataset is desired.'
                )
            elif not old_data['pool']:
                verrors.add(
                    f'{schema}.migrate_applications',
                    'A pool must have been configured previously for ix-application dataset migration.'
                )
            else:
                if await self.middleware.call(
                        'zfs.dataset.query',
                    [['id', '=', applications_ds_name(data['pool'])]], {
                        'extra': {
                            'retrieve_children': False,
                            'retrieve_properties': False
                        }
                    }):
                    verrors.add(
                        f'{schema}.migrate_applications',
                        f'Migration of {applications_ds_name(old_data["pool"])!r} to {data["pool"]!r} not '
                        f'possible as {applications_ds_name(data["pool"])} already exists.'
                    )

                if not await self.middleware.call(
                        'zfs.dataset.query',
                    [['id', '=',
                      applications_ds_name(old_data['pool'])]], {
                          'extra': {
                              'retrieve_children': False,
                              'retrieve_properties': False
                          }
                      }):
                    # Edge case but handled just to be sure
                    verrors.add(
                        f'{schema}.migrate_applications',
                        f'{applications_ds_name(old_data["pool"])!r} does not exist, migration not possible.'
                    )

        network_cidrs = set([
            ipaddress.ip_network(
                f'{ip_config["address"]}/{ip_config["netmask"]}', False)
            for interface in await self.middleware.call('interface.query')
            for ip_config in itertools.chain(interface['aliases'],
                                             interface['state']['aliases'])
            if ip_config['type'] != 'LINK'
        ])

        unused_cidrs = []
        if not data['cluster_cidr'] or not data['service_cidr']:
            unused_cidrs = await self.unused_cidrs(network_cidrs)
            # If index 0,1 belong to different classes, let's make sure that is not the case anymore
            if len(unused_cidrs) > 2 and unused_cidrs[0].split(
                    '.')[0] != unused_cidrs[1].split('.')[0]:
                unused_cidrs.pop(0)

        if unused_cidrs and not data['cluster_cidr']:
            data['cluster_cidr'] = unused_cidrs.pop(0)

        if unused_cidrs and not data['service_cidr']:
            data['service_cidr'] = unused_cidrs.pop(0)

        if not data['cluster_dns_ip']:
            if data['service_cidr']:
                # Picking 10th ip ( which is the usual default ) from service cidr
                data['cluster_dns_ip'] = str(
                    list(
                        ipaddress.ip_network(data['service_cidr'],
                                             False).hosts())[9])
            else:
                verrors.add(f'{schema}.cluster_dns_ip',
                            'Please specify cluster_dns_ip.')

        if data['pool'] and not await self.middleware.call(
                'pool.query', [['name', '=', data['pool']]]):
            verrors.add(
                f'{schema}.pool',
                'Please provide a valid pool configured in the system.')

        for k in ('cluster_cidr', 'service_cidr'):
            if not data[k]:
                verrors.add(f'{schema}.{k}',
                            f'Please specify a {k.split("_")[0]} CIDR.')
            elif any(
                    ipaddress.ip_network(data[k], False).overlaps(cidr)
                    for cidr in network_cidrs):
                verrors.add(f'{schema}.{k}',
                            'Requested CIDR is already in use.')

        if data['cluster_cidr'] and data[
                'service_cidr'] and ipaddress.ip_network(
                    data['cluster_cidr'], False).overlaps(
                        ipaddress.ip_network(data['service_cidr'], False)):
            verrors.add(f'{schema}.cluster_cidr',
                        'Must not overlap with service CIDR.')

        if data['service_cidr'] and data[
                'cluster_dns_ip'] and ipaddress.ip_address(
                    data['cluster_dns_ip']) not in ipaddress.ip_network(
                        data['service_cidr']):
            verrors.add(f'{schema}.cluster_dns_ip',
                        'Must be in range of "service_cidr".')

        if data['node_ip'] not in await self.bindip_choices():
            verrors.add(f'{schema}.node_ip',
                        'Please provide a valid IP address.')

        if not await self.middleware.call('route.configured_default_ipv4_route'
                                          ):
            verrors.add(
                f'{schema}.route_v4_interface',
                'Please, set IPv4 Default Gateway (it can be fake) in Network → Global Configuration and then '
                'update Kubernetes settings. Currently, k3s cannot be used without a default route.'
            )

        valid_choices = await self.route_interface_choices()
        for k, _ in await self.validate_interfaces(data):
            verrors.add(
                f'{schema}.{k}',
                f'Please specify a valid interface (i.e {", ".join(valid_choices)!r}).'
            )

        for k in ('route_v4', 'route_v6'):
            gateway = data[f'{k}_gateway']
            interface = data[f'{k}_interface']
            if (not gateway and not interface) or (gateway and interface):
                continue
            for k2 in ('gateway', 'interface'):
                verrors.add(
                    f'{schema}.{k}_{k2}',
                    f'{k}_gateway and {k}_interface must be specified together.'
                )

        if data['route_v4_gateway']:
            gateway = ipaddress.ip_address(data['route_v4_gateway'])
            if not any(gateway in network_cidr
                       for network_cidr in network_cidrs):
                verrors.add(
                    f'{schema}.route_v4_gateway',
                    'Specified value is not present on any network cidr in use by the system'
                )

        verrors.check()

    @private
    async def validate_interfaces(self, data):
        errors = []
        interfaces = await self.route_interface_choices()
        for k in filter(lambda k: data[k] and data[k] not in interfaces,
                        ('route_v4_interface', 'route_v6_interface')):
            errors.append((k, data[k]))
        return errors

    @private
    async def validate_config(self):
        data = await self.middleware.call('kubernetes.config')
        data.pop('id')
        data.pop('dataset')

        try:
            await self.validate_data(data, 'kubernetes', data)
        except ValidationErrors as e:
            return e

    @accepts(
        Patch(
            'kubernetes_entry',
            'kubernetes_update',
            ('add', Bool('migrate_applications')),
            ('rm', {
                'name': 'id'
            }),
            ('rm', {
                'name': 'dataset'
            }),
            ('attr', {
                'update': True
            }),
        ))
    @job(lock='kubernetes_update')
    async def do_update(self, job, data):
        """
        `pool` must be a valid ZFS pool configured in the system. Kubernetes service will initialise the pool by
        creating datasets under `pool_name/ix-applications`.

        `configure_gpus` is a boolean to enable or disable to prevent automatically loading any GPU Support
        into kubernetes. This includes not loading any daemonsets for Intel and NVIDIA support.

        `servicelb` is a boolean to enable or disable the integrated k3s Service Loadbalancer called "Klipper".
        This can be set to disabled to enable the user to run another LoadBalancer or no LoadBalancer at all.

        `cluster_cidr` is the CIDR to be used for default NAT network between workloads.

        `service_cidr` is the CIDR to be used for kubernetes services which are an abstraction and refer to a
        logically set of kubernetes pods.

        `cluster_dns_ip` is the IP of the DNS server running for the kubernetes cluster. It must be in the range
        of `service_cidr`.

        Specifying values for `cluster_cidr`, `service_cidr` and `cluster_dns_ip` are permanent and a subsequent change
        requires re-initialisation of the applications. To clarify, system will destroy old `ix-applications` dataset
        and any data within it when any of the values for the above configuration change.

        `node_ip` is the IP address which the kubernetes cluster will assign to the TrueNAS node. It defaults to
        0.0.0.0 and the cluster in this case will automatically manage which IP address to use for managing traffic
        for default NAT network.

        By default kubernetes pods will be using default gateway of the system for outward traffic. This might
        not be desirable for certain users who want to separate NAT traffic over a specific interface / route. System
        will create a L3 network which will be routing the traffic towards default gateway for NAT.

        If users want to restrict traffic over a certain gateway / interface, they can specify a default route
        for the NAT traffic. `route_v4_interface` and `route_v4_gateway` will set a default route for the kubernetes
        cluster IPv4 traffic. Similarly `route_v6_interface` and 'route_v6_gateway` can be used to specify default
        route for IPv6 traffic.

        In case user is switching pools and the new desired pool has not been configured for kubernetes before, it
        is possible to replicate data from old pool to new pool with setting `migrate_applications` attribute. This
        will replicate contents of old pool's ix-applications dataset to the new pool.
        """
        old_config = await self.config()
        old_config.pop('dataset')
        config = old_config.copy()
        config.update(data)
        migrate = config.get('migrate_applications')

        await self.validate_data(config, 'kubernetes_update', old_config)

        if migrate and config['pool'] != old_config['pool']:
            job.set_progress(
                25,
                f'Migrating {applications_ds_name(old_config["pool"])} to {applications_ds_name(config["pool"])}'
            )
            await self.middleware.call(
                'kubernetes.migrate_ix_applications_dataset', config['pool'],
                old_config['pool'])
            job.set_progress(40,
                             'Migration complete for ix-applications dataset')

        if len(set(old_config.items()) ^ set(config.items())) > 0:
            await self.middleware.call(
                'chart.release.clear_update_alerts_for_all_chart_releases')
            config['cni_config'] = {}
            await self.middleware.call('datastore.update',
                                       self._config.datastore,
                                       old_config['id'], config)
            await self.middleware.call('kubernetes.status_change')
            if not config['pool'] and config['pool'] != old_config['pool']:
                # We only want to do this when we don't have any pool configured and would like to use
                # host catalog repos temporarily. Otherwise, we should call this after k8s datasets have
                # been initialised
                await self.middleware.call('catalog.sync_all')

        return await self.config()

    @accepts()
    @returns(Dict(
        'kubernetes_bind_ip_choices',
        additional_attrs=True,
    ))
    async def bindip_choices(self):
        """
        Returns ip choices for Kubernetes service to use.
        """
        return {
            d['address']: d['address']
            for d in await self.middleware.call('interface.ip_in_use', {
                'static': True,
                'any': True,
                'ipv6': False
            })
        }

    @accepts()
    @returns(Dict(additional_attrs=True))
    async def route_interface_choices(self):
        """
        Returns Interface choices for Kubernetes service to use for ipv4 connections.
        """
        return await self.middleware.call('interface.choices', {
            'bridge_members': False,
            'lag_ports': False
        })

    @private
    async def validate_k8s_setup(self, raise_exception=True):
        error = None
        k8s_config = await self.middleware.call('kubernetes.config')
        if not k8s_config['dataset']:
            error = 'Please configure kubernetes pool.'
        if not error and not await self.middleware.call(
                'service.started', 'kubernetes'):
            error = 'Kubernetes service is not running.'

        if not error:
            try:
                async with api_client({'node': True},
                                      {'request_timeout': 2}) as (api,
                                                                  context):
                    pass
            except asyncio.exceptions.TimeoutError:
                error = 'Unable to connect to kubernetes cluster'

        if error and raise_exception:
            raise CallError(error)
        return not error

    @accepts()
    @returns(Str('kubernetes_node_ip', null=True))
    async def node_ip(self):
        """
        Returns IP used by kubernetes which kubernetes uses to allow incoming connections.
        """
        node_ip = None
        if await self.validate_k8s_setup(False):
            k8s_node_config = await self.middleware.call('k8s.node.config')
            if k8s_node_config['node_configured']:
                node_ip = next(
                    (addr['address']
                     for addr in k8s_node_config['status']['addresses']
                     if addr['type'] == 'InternalIP'), None)
        if not node_ip:
            node_ip = (await
                       self.middleware.call('kubernetes.config'))['node_ip']

        return node_ip
示例#30
0
class DynDNSService(SystemServiceService):
    class Config:
        service = "dynamicdns"
        datastore_extend = "dyndns.dyndns_extend"
        datastore_prefix = "ddns_"

    @private
    async def dyndns_extend(self, dyndns):
        dyndns["password"] = await self.middleware.call(
            "pwenc.decrypt", dyndns["password"])
        dyndns["domain"] = dyndns["domain"].replace(',',
                                                    ' ').replace(';',
                                                                 ' ').split()
        return dyndns

    @accepts()
    async def provider_choices(self):
        """
        List supported Dynamic DNS Service Providers.
        """
        return {
            '*****@*****.**': 'changeip.com',
            '*****@*****.**': 'cloudxns.net',
            '*****@*****.**': 'ddnss.de',
            '*****@*****.**': 'dhis.org',
            '*****@*****.**': 'dnsexit.com',
            '*****@*****.**': 'dnsomatic.com',
            '*****@*****.**': 'dnspod.cn',
            '*****@*****.**': 'domains.google.com',
            '*****@*****.**': 'dtdns.com',
            '*****@*****.**': 'duckdns.org',
            '*****@*****.**': 'duiadns.net',
            '*****@*****.**': 'dyndns.org',
            '*****@*****.**': 'dynsip.org',
            '*****@*****.**': 'dynv6.com',
            '*****@*****.**': 'easydns.com',
            '*****@*****.**': 'freedns.afraid.org',
            '*****@*****.**': 'freemyip.com',
            '*****@*****.**': 'gira.de',
            '*****@*****.**': 'ipv4.dynv6.com',
            '*****@*****.**': 'loopia.com',
            '*****@*****.**': 'no-ip.com',
            '*****@*****.**': 'ovh.com',
            '*****@*****.**': 'sitelutions.com',
            '*****@*****.**': 'spdyn.de',
            '*****@*****.**': 'strato.com',
            '*****@*****.**': 'tunnelbroker.net',
            '*****@*****.**': 'tzo.com',
            '*****@*****.**': 'zerigo.com',
            '*****@*****.**': 'zoneedit.com',
            '*****@*****.**': '3322.org',
            '*****@*****.**': 'nsupdate.info',
            '*****@*****.**': 'he.net'
        }

    @accepts(
        Dict('dyndns_update',
             Str('provider'),
             Bool('checkip_ssl'),
             Str('checkip_server'),
             Str('checkip_path'),
             Bool('ssl'),
             Str('custom_ddns_server'),
             Str('custom_ddns_path'),
             List('domain', items=[Str('domain')]),
             Str('username'),
             Str('password'),
             Int('period'),
             update=True))
    async def do_update(self, data):
        """
        Update dynamic dns service configuration.

        `period` indicates how often the IP is checked in seconds.

        `ssl` if set to true, makes sure that HTTPS is used for the connection to the server which updates the
        DNS record.
        """
        old = await self.config()

        new = old.copy()
        new.update(data)

        new["domain"] = " ".join(new["domain"])
        new["password"] = await self.middleware.call("pwenc.encrypt",
                                                     new["password"])

        await self._update_service(old, new)

        await self.dyndns_extend(new)

        return await self.config()