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
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
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')
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}
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}'])
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}')
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
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
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.' )
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
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)
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
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')
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
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}'])
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)
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)
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
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
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
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
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
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))
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.')
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()
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')
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')
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
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()