コード例 #1
0
def generate_only(hostname: str) -> (str, dict):
    """
    Generate configuration for a device and return it as a text string.

    Args:
        hostname: Hostname of device generate config for

    Returns:
        (string with config, dict with available template variables)
    """
    logger = get_logger()
    nr = cnaas_init()
    nr_filtered, _, _ = inventory_selector(nr, hostname=hostname)
    template_vars = {}
    if len(nr_filtered.inventory.hosts) != 1:
        raise ValueError("Invalid hostname: {}".format(hostname))
    try:
        nrresult = nr_filtered.run(task=push_sync_device, generate_only=True)
        if nrresult[hostname][0].failed:
            raise Exception("Could not generate config for device {}: {}".format(
                hostname, nrresult[hostname][0].result
            ))
        if "template_vars" in nrresult[hostname][1].host:
            template_vars = nrresult[hostname][1].host["template_vars"]
        if nrresult.failed:
            print_result(nrresult)
            raise Exception("Failed to generate config for {}".format(hostname))

        return nrresult[hostname][1].result, template_vars
    except Exception as e:
        logger.exception("Exception while generating config: {}".format(str(e)))
        if len(nrresult[hostname]) >= 2:
            return nrresult[hostname][1].result, template_vars
        else:
            return str(e), template_vars
コード例 #2
0
def device_upgrade(download: Optional[bool] = False,
                   activate: Optional[bool] = False,
                   filename: Optional[bool] = None,
                   group: Optional[str] = None,
                   hostname: Optional[str] = None,
                   url: Optional[str] = None,
                   job_id: Optional[str] = None,
                   pre_flight: Optional[bool] = False,
                   reboot: Optional[bool] = False,
                   scheduled_by: Optional[str] = None) -> NornirJobResult:

    nr = cnaas_init()
    if hostname:
        nr_filtered, dev_count, _ = inventory_selector(nr, hostname=hostname)
    elif group:
        nr_filtered, dev_count, _ = inventory_selector(nr, group=group)
    else:
        raise ValueError(
            "Neither hostname nor group specified for device_upgrade")

    device_list = list(nr_filtered.inventory.hosts.keys())
    logger.info("Device(s) selected for firmware upgrade ({}): {}".format(
        dev_count, ", ".join(device_list)))

    # Make sure we only upgrade Arista access switches
    for device in device_list:
        with sqla_session() as session:
            dev: Device = session.query(Device).\
                filter(Device.hostname == device).one_or_none()
            if not dev:
                raise Exception('Could not find device: {}'.format(device))
            if dev.platform != 'eos':
                raise Exception(
                    'Invalid device platform "{}" for device: {}'.format(
                        dev.platform, device))

    # Start tasks to take care of the upgrade
    try:
        nrresult = nr_filtered.run(task=device_upgrade_task,
                                   job_id=job_id,
                                   download=download,
                                   filename=filename,
                                   url=url,
                                   pre_flight=pre_flight,
                                   reboot=reboot,
                                   activate=activate)
        print_result(nrresult)
    except Exception as e:
        logger.exception('Exception while upgrading devices: {}'.format(
            str(e)))
        return NornirJobResult(nrresult=nrresult)

    failed_hosts = list(nrresult.failed_hosts.keys())
    for hostname in failed_hosts:
        logger.error("Firmware upgrade of device '{}' failed".format(hostname))

    if nrresult.failed:
        logger.error("Not all devices were successfully upgraded")

    return NornirJobResult(nrresult=nrresult)
コード例 #3
0
    def copy_cert(self):
        nr = cnaas_init()
        nr_filtered = nr.filter(name=self.testdata['copycert_hostname'])

        nrresult = nr_filtered.run(
            task=arista_copy_cert
        )
        if nrresult.failed:
            print_result(nrresult)
        self.assertFalse(nrresult.failed, "Task arista_copy_cert returned failed status")
コード例 #4
0
def confcheck_devices(hostnames: List[str], job_id=None):
    nr = cnaas_init()
    nr_filtered, dev_count, skipped_hostnames = \
        inventory_selector(nr, hostname=hostnames)

    try:
        nrresult = nr_filtered.run(task=sync_check_hash,
                                   job_id=job_id)
    except Exception as e:
        raise e
    else:
        if nrresult.failed:
            raise Exception('Configuration hash check failed for {}'.format(
                ' '.join(nrresult.failed_hosts.keys())))
コード例 #5
0
ファイル: interface_state.py プロジェクト: SUNET/cnaas-nms
def get_interface_states(hostname) -> dict:
    logger = get_logger()

    nr = cnaas_init()
    nr_filtered = nr.filter(name=hostname).filter(managed=True)
    if len(nr_filtered.inventory) != 1:
        raise ValueError(f"Hostname {hostname} not found in inventory")
    nrresult = nr_filtered.run(task=napalm_get, getters=["interfaces"])
    if not len(nrresult) == 1:
        raise Exception(f"Could not get interfaces for {hostname}: no Nornir result")
    if nrresult.failed or nrresult[hostname].failed:
        raise Exception("Could not get interfaces for {}, NAPALM failed: {}".format(
            hostname, nrresult[hostname].exception
        ))
    return nrresult[hostname][0].result['interfaces']
コード例 #6
0
ファイル: cert.py プロジェクト: SUNET/cnaas-nms
def renew_cert(hostname: Optional[str] = None,
               group: Optional[str] = None,
               job_id: Optional[str] = None,
               scheduled_by: Optional[str] = None) -> NornirJobResult:

    logger = get_logger()
    nr = cnaas_init()
    if hostname:
        nr_filtered, dev_count, _ = inventory_selector(nr, hostname=hostname)
    elif group:
        nr_filtered, dev_count, _ = inventory_selector(nr, group=group)
    else:
        raise ValueError("Neither hostname nor group specified for renew_cert")

    device_list = list(nr_filtered.inventory.hosts.keys())
    logger.info("Device(s) selected for renew certificate ({}): {}".format(
        dev_count, ", ".join(device_list)))

    supported_platforms = ['eos']
    # Make sure we only attempt supported devices
    for device in device_list:
        with sqla_session() as session:
            dev: Device = session.query(Device). \
                filter(Device.hostname == device).one_or_none()
            if not dev:
                raise Exception('Could not find device: {}'.format(device))
            if dev.platform not in supported_platforms:
                raise Exception(
                    'Unsupported device platform "{}" for device: {}'.format(
                        dev.platform, device))

    try:
        nrresult = nr_filtered.run(task=renew_cert_task, job_id=job_id)
    except Exception as e:
        logger.exception('Exception while renewing certificates: {}'.format(
            str(e)))
        return NornirJobResult(nrresult=nrresult)

    failed_hosts = list(nrresult.failed_hosts.keys())
    for hostname in failed_hosts:
        logger.error(
            "Certificate renew on device '{}' failed".format(hostname))

    if nrresult.failed:
        logger.error("Not all devices got new certificates")

    return NornirJobResult(nrresult=nrresult)
コード例 #7
0
ファイル: interface_state.py プロジェクト: SUNET/cnaas-nms
def bounce_interfaces(hostname: str, interfaces: List[str]) -> bool:
    """Returns true if the device changed config down and then up.
    Returns false if config did not change, and raises Exception if an
    error was encountered."""
    pre_bounce_check(hostname, interfaces)
    nr = cnaas_init()
    nr_filtered = nr.filter(name=hostname).filter(managed=True)
    if len(nr_filtered.inventory) != 1:
        raise ValueError(f"Hostname {hostname} not found in inventory")
    nrresult = nr_filtered.run(task=bounce_task, interfaces=interfaces)
    # 5 results: bounce_task, gen down config, push down config, gen up config, push up config
    if not len(nrresult[hostname]) == 5:
        raise Exception("Not all steps of port bounce completed")
    if nrresult[hostname][2].changed and nrresult[hostname][4].changed:
        return True
    else:
        return False
コード例 #8
0
ファイル: sync_devices.py プロジェクト: katsel/cnaas-nms
def apply_config(hostname: str,
                 config: str,
                 dry_run: bool,
                 job_id: Optional[int] = None,
                 scheduled_by: Optional[str] = None) -> NornirJobResult:
    """Apply a static configuration (from backup etc) to a device.

    Args:
        hostname: Specify a single host by hostname to synchronize
        config: Static configuration to apply
        dry_run: Set to false to actually apply config to device
        job_id: Job ID number
        scheduled_by: Username from JWT

    Returns:
        NornirJobResult
    """
    logger = get_logger()

    with sqla_session() as session:
        dev: Device = session.query(Device).filter(
            Device.hostname == hostname).one_or_none()
        if not dev:
            raise Exception("Device {} not found".format(hostname))
        elif not (dev.state == DeviceState.MANAGED
                  or dev.state == DeviceState.UNMANAGED):
            raise Exception("Device {} is in invalid state: {}".format(
                hostname, dev.state))
        if not dry_run:
            dev.state = DeviceState.UNMANAGED
            dev.synchronized = False

    nr = cnaas_init()
    nr_filtered, _, _ = inventory_selector(nr, hostname=hostname)

    try:
        nrresult = nr_filtered.run(task=push_static_config,
                                   config=config,
                                   dry_run=dry_run,
                                   job_id=job_id)
    except Exception as e:
        logger.exception("Exception in apply_config: {}".format(e))

    return NornirJobResult(nrresult=nrresult)
コード例 #9
0
def sync_devices(hostnames: Optional[List[str]] = None, device_type: Optional[str] = None,
                 group: Optional[str] = None, dry_run: bool = True, force: bool = False,
                 auto_push: bool = False, job_id: Optional[int] = None,
                 scheduled_by: Optional[str] = None, resync: bool = False) -> NornirJobResult:
    """Synchronize devices to their respective templates. If no arguments
    are specified then synchronize all devices that are currently out
    of sync.

    Args:
        hostname: Specify a single host by hostname to synchronize
        device_type: Specify a device type to synchronize
        group: Specify a group of devices to synchronize
        dry_run: Don't commit generated config to device
        force: Commit config even if changes made outside CNaaS will get
               overwritten
        auto_push: Automatically do live-run after dry-run if change score is low
        job_id: job_id provided by scheduler when adding a new job
        scheduled_by: Username from JWT
        resync: Re-synchronize a device even if it's marked as synced in the
                database, a device selected by hostname is always re-synced

    Returns:
        NornirJobResult
    """
    logger = get_logger()
    nr = cnaas_init()
    dev_count = 0
    skipped_hostnames = []
    if hostnames:
        nr_filtered, dev_count, skipped_hostnames = \
            inventory_selector(nr, hostname=hostnames)
    else:
        if device_type:
            nr_filtered, dev_count, skipped_hostnames = \
                inventory_selector(nr, resync=resync, device_type=device_type)
        elif group:
            nr_filtered, dev_count, skipped_hostnames = \
                inventory_selector(nr, resync=resync, group=group)
        else:
            # all devices
            nr_filtered, dev_count, skipped_hostnames = \
                inventory_selector(nr, resync=resync)

    if skipped_hostnames:
        logger.info("Device(s) already synchronized, skipping ({}): {}".format(
            len(skipped_hostnames), ", ".join(skipped_hostnames)
        ))

    device_list = list(nr_filtered.inventory.hosts.keys())
    logger.info("Device(s) selected for synchronization ({}): {}".format(
        dev_count, ", ".join(device_list)
    ))

    try:
        nrresult = nr_filtered.run(task=sync_check_hash,
                                   force=force,
                                   job_id=job_id)
    except Exception as e:
        logger.exception("Exception while checking config hash: {}".format(str(e)))
        raise e
    else:
        if nrresult.failed:
            # Mark devices as unsynchronized if config hash check failed
            with sqla_session() as session:
                session.query(Device).filter(Device.hostname.in_(nrresult.failed_hosts.keys())).\
                    update({Device.synchronized: False}, synchronize_session=False)
            raise Exception('Configuration hash check failed for {}'.format(
                ' '.join(nrresult.failed_hosts.keys())))

    if not dry_run:
        with sqla_session() as session:
            logger.info("Trying to acquire lock for devices to run syncto job: {}".format(job_id))
            if not Joblock.acquire_lock(session, name='devices', job_id=job_id):
                raise JoblockError("Unable to acquire lock for configuring devices")

    try:
        nrresult = nr_filtered.run(task=push_sync_device, dry_run=dry_run,
                                   job_id=job_id)
    except Exception as e:
        logger.exception("Exception while synchronizing devices: {}".format(str(e)))
        try:
            if not dry_run:
                with sqla_session() as session:
                    logger.info("Releasing lock for devices from syncto job: {}".format(job_id))
                    Joblock.release_lock(session, job_id=job_id)
        except Exception:
            logger.error("Unable to release devices lock after syncto job")
        return NornirJobResult(nrresult=nrresult)

    failed_hosts = list(nrresult.failed_hosts.keys())
    for hostname in failed_hosts:
        logger.error("Synchronization of device '{}' failed".format(hostname))

    if nrresult.failed:
        logger.error("Not all devices were successfully synchronized")

    total_change_score = 1
    change_scores = []
    changed_hosts = []
    unchanged_hosts = []
    # calculate change impact score
    for host, results in nrresult.items():
        if len(results) != 3:
            logger.debug("Unable to calculate change score for failed device {}".format(host))
        elif results[2].diff:
            changed_hosts.append(host)
            if "change_score" in results[0].host:
                change_scores.append(results[0].host["change_score"])
                logger.debug("Change score for host {}: {:.1f}".format(
                    host, results[0].host["change_score"]))
        else:
            unchanged_hosts.append(host)
            change_scores.append(0)
            logger.debug("Empty diff for host {}, 0 change score".format(
                host))

    nr_confighash = None
    if dry_run and force:
        # update config hash for devices that had an empty diff because local
        # changes on a device can cause reordering of CLI commands that results
        # in config hash mismatch even if the calculated diff was empty
        def include_filter(host, include_list=unchanged_hosts):
            if host.name in include_list:
                return True
            else:
                return False
        nr_confighash = nr_filtered.filter(filter_func=include_filter)
    elif not dry_run:
        # set new config hash for devices that was successfully updated
        def exclude_filter(host, exclude_list=failed_hosts+unchanged_hosts):
            if host.name in exclude_list:
                return False
            else:
                return True
        nr_confighash = nr_filtered.filter(filter_func=exclude_filter)

    if nr_confighash:
        try:
            nrresult_confighash = nr_confighash.run(task=update_config_hash)
        except Exception as e:
            logger.exception("Exception while updating config hashes: {}".format(str(e)))
        else:
            if nrresult_confighash.failed:
                logger.error("Unable to update some config hashes: {}".format(
                    list(nrresult_confighash.failed_hosts.keys())))

    # set devices as synchronized if needed
    with sqla_session() as session:
        for hostname in changed_hosts:
            if dry_run:
                dev: Device = session.query(Device).filter(Device.hostname == hostname).one()
                dev.synchronized = False
            else:
                dev: Device = session.query(Device).filter(Device.hostname == hostname).one()
                dev.synchronized = True
        for hostname in unchanged_hosts:
            dev: Device = session.query(Device).filter(Device.hostname == hostname).one()
            dev.synchronized = True
        if not dry_run:
            logger.info("Releasing lock for devices from syncto job: {}".format(job_id))
            Joblock.release_lock(session, job_id=job_id)

    if len(device_list) == 0:
        total_change_score = 0
    elif not change_scores or total_change_score >= 100 or failed_hosts:
        total_change_score = 100
    else:
        # use individual max as total_change_score, range 1-100
        total_change_score = max(min(int(max(change_scores) + 0.5), 100), 1)
    logger.info(
        "Change impact score: {:.1f} (dry_run: {}, selected devices: {}, changed devices: {})".
            format(total_change_score, dry_run, len(device_list), len(changed_hosts)))

    next_job_id = None
    if auto_push and len(device_list) == 1 and hostnames and dry_run:
        if not changed_hosts:
            logger.info("None of the selected host has any changes (diff), skipping auto-push")
        elif total_change_score < AUTOPUSH_MAX_SCORE:
            scheduler = Scheduler()
            next_job_id = scheduler.add_onetime_job(
                'cnaas_nms.confpush.sync_devices:sync_devices',
                when=0,
                scheduled_by=scheduled_by,
                kwargs={'hostnames': hostnames, 'dry_run': False, 'force': force})
            logger.info(f"Auto-push scheduled live-run of commit as job id {next_job_id}")
        else:
            logger.info(
                f"Auto-push of config to device {hostnames} failed because change score of "
                f"{total_change_score} is higher than auto-push limit {AUTOPUSH_MAX_SCORE}"
            )

    return NornirJobResult(nrresult=nrresult, next_job_id=next_job_id, change_score=total_change_score)
コード例 #10
0
    def post(self):
        """ Start sync of device(s) """
        json_data = request.get_json()
        # default args
        kwargs: dict = {
            'dry_run': True,
            'auto_push': False,
            'force': False,
            'resync': False
        }

        if 'dry_run' in json_data and isinstance(json_data['dry_run'], bool) \
                and not json_data['dry_run']:
            kwargs['dry_run'] = False
        if 'force' in json_data and isinstance(json_data['force'], bool):
            kwargs['force'] = json_data['force']
        if 'auto_push' in json_data and isinstance(json_data['auto_push'],
                                                   bool):
            kwargs['auto_push'] = json_data['auto_push']
        if 'resync' in json_data and isinstance(json_data['resync'], bool):
            kwargs['resync'] = json_data['resync']
        if 'comment' in json_data and isinstance(json_data['comment'], str):
            kwargs['job_comment'] = json_data['comment']
        if 'ticket_ref' in json_data and isinstance(json_data['ticket_ref'],
                                                    str):
            kwargs['job_ticket_ref'] = json_data['ticket_ref']

        total_count: Optional[int] = None
        nr = cnaas_init()

        if 'hostname' in json_data:
            hostname = str(json_data['hostname'])
            if not Device.valid_hostname(hostname):
                return empty_result(
                    status='error',
                    data=f"Hostname '{hostname}' is not a valid hostname"), 400
            _, total_count, _ = inventory_selector(nr, hostname=hostname)
            if total_count != 1:
                return empty_result(
                    status='error',
                    data=
                    f"Hostname '{hostname}' not found or is not a managed device"
                ), 400
            kwargs['hostnames'] = [hostname]
            what = hostname
        elif 'device_type' in json_data:
            devtype_str = str(json_data['device_type']).upper()
            if DeviceType.has_name(devtype_str):
                kwargs['device_type'] = devtype_str
            else:
                return empty_result(
                    status='error',
                    data=
                    f"Invalid device type '{json_data['device_type']}' specified"
                ), 400
            what = f"{json_data['device_type']} devices"
            _, total_count, _ = inventory_selector(nr,
                                                   resync=kwargs['resync'],
                                                   device_type=devtype_str)
        elif 'group' in json_data:
            group_name = str(json_data['group'])
            if group_name not in get_groups():
                return empty_result(
                    status='error',
                    data='Could not find a group with name {}'.format(
                        group_name))
            kwargs['group'] = group_name
            what = 'group {}'.format(group_name)
            _, total_count, _ = inventory_selector(nr,
                                                   resync=kwargs['resync'],
                                                   group=group_name)
        elif 'all' in json_data and isinstance(json_data['all'],
                                               bool) and json_data['all']:
            what = "all devices"
            _, total_count, _ = inventory_selector(nr, resync=kwargs['resync'])
        else:
            return empty_result(
                status='error',
                data=f"No devices to synchronize were specified"), 400

        scheduler = Scheduler()
        job_id = scheduler.add_onetime_job(
            'cnaas_nms.confpush.sync_devices:sync_devices',
            when=1,
            scheduled_by=get_jwt_identity(),
            kwargs=kwargs)

        res = empty_result(data=f"Scheduled job to synchronize {what}")
        res['job_id'] = job_id

        resp = make_response(json.dumps(res), 200)
        if total_count:
            resp.headers['X-Total-Count'] = total_count
        resp.headers['Content-Type'] = "application/json"
        return resp
コード例 #11
0
    def post(self):
        """Execute certificate related actions on device"""
        json_data = request.get_json()
        # default args
        kwargs: dict = {}

        if 'action' in json_data and isinstance(json_data['action'], str):
            action = json_data['action'].upper()
        else:
            return empty_result(
                status='error',
                data=f"Required field 'action' was not specified"), 400

        if 'comment' in json_data and isinstance(json_data['comment'], str):
            kwargs['job_comment'] = json_data['comment']
        if 'ticket_ref' in json_data and isinstance(json_data['ticket_ref'],
                                                    str):
            kwargs['job_ticket_ref'] = json_data['ticket_ref']

        total_count: Optional[int] = None
        nr = cnaas_init()

        if 'hostname' in json_data:
            hostname = str(json_data['hostname'])
            if not Device.valid_hostname(hostname):
                return empty_result(
                    status='error',
                    data=f"Hostname '{hostname}' is not a valid hostname"), 400
            _, total_count, _ = inventory_selector(nr, hostname=hostname)
            if total_count != 1:
                return empty_result(
                    status='error',
                    data=
                    f"Hostname '{hostname}' not found or is not a managed device"
                ), 400
            kwargs['hostname'] = hostname
        elif 'group' in json_data:
            group_name = str(json_data['group'])
            if group_name not in get_groups():
                return empty_result(
                    status='error',
                    data='Could not find a group with name {}'.format(
                        group_name))
            kwargs['group'] = group_name
            _, total_count, _ = inventory_selector(nr, group=group_name)
        else:
            return empty_result(status='error',
                                data=f"No devices were specified"), 400

        if action == 'RENEW':
            scheduler = Scheduler()
            job_id = scheduler.add_onetime_job(
                'cnaas_nms.confpush.cert:renew_cert',
                when=1,
                scheduled_by=get_jwt_identity(),
                kwargs=kwargs)

            res = empty_result(data=f"Scheduled job to renew certificates")
            res['job_id'] = job_id

            resp = make_response(json.dumps(res), 200)
            if total_count:
                resp.headers['X-Total-Count'] = total_count
            resp.headers['Content-Type'] = "application/json"
            return resp
        else:
            return empty_result(
                status='error',
                data=f"Unknown action specified: {action}"), 400
コード例 #12
0
    def post(self):
        """ Upgrade firmware on device """
        json_data = request.get_json()

        kwargs = dict()
        seconds = 1
        date_format = "%Y-%m-%d %H:%M:%S"
        url = self.firmware_url()

        if 'url' not in json_data and url == '':
            return empty_result(status='error',
                                data='No external address configured for '
                                'HTTPD, please specify one with "url"')

        if 'url' not in json_data:
            kwargs['url'] = url
        else:
            if isinstance(json_data['url'], str):
                kwargs['url'] = json_data['url']
            else:
                return empty_result(status='error',
                                    data='url should be a string')

        if 'activate' in json_data:
            if isinstance(json_data['activate'], bool):
                kwargs['activate'] = json_data['activate']
            else:
                return empty_result(status='error',
                                    data='activate should be a boolean')

        if 'download' in json_data:
            if isinstance(json_data['download'], bool):
                kwargs['download'] = json_data['download']
            else:
                return empty_result(status='error',
                                    data='download should be a boolean')

        if 'reboot' in json_data:
            if isinstance(json_data['reboot'], bool):
                kwargs['reboot'] = json_data['reboot']
            else:
                return empty_result(status='error',
                                    data='reboot should be a boolean')

        if 'pre_flight' in json_data:
            if isinstance(json_data['pre_flight'], bool):
                kwargs['pre_flight'] = json_data['pre_flight']
            else:
                return empty_result(status='error',
                                    data='pre_flight should be a boolean')

        if 'post_flight' in json_data:
            if isinstance(json_data['post_flight'], bool):
                kwargs['post_flight'] = json_data['post_flight']
            else:
                return empty_result(status='error',
                                    data='post_flight should be a boolean')

        if 'post_waittime' in json_data:
            if isinstance(json_data['post_waittime'], int):
                kwargs['post_waittime'] = json_data['post_waittime']
            else:
                return empty_result(status='error',
                                    data='post_waittime should be an integer')

        if 'filename' in json_data:
            if isinstance(json_data['filename'], str):
                kwargs['filename'] = json_data['filename']
            else:
                return empty_result(status='error',
                                    data='filename should be a string')

        total_count: Optional[int] = None
        nr = cnaas_init()

        if 'hostname' in json_data:
            hostname = str(json_data['hostname'])
            if not Device.valid_hostname(hostname):
                return empty_result(
                    status='error',
                    data=f"Hostname '{hostname}' is not a valid hostname"
                ), 400
            _, total_count, _ = inventory_selector(nr, hostname=hostname)
            if total_count != 1:
                return empty_result(
                    status='error',
                    data=f"Hostname '{hostname}' not found or is not a managed device"
                ), 400
            kwargs['hostname'] = hostname
        elif 'group' in json_data:
            group_name = str(json_data['group'])
            if group_name not in get_groups():
                return empty_result(status='error', data='Could not find a group with name {}'.format(group_name))
            kwargs['group'] = group_name
            _, total_count, _ = inventory_selector(nr, group=group_name)
            kwargs['group'] = group_name
        else:
            return empty_result(
                status='error',
                data=f"No devices to upgrade were specified"
            ), 400

        if 'comment' in json_data and isinstance(json_data['comment'], str):
            kwargs['job_comment'] = json_data['comment']
        if 'ticket_ref' in json_data and isinstance(json_data['ticket_ref'], str):
            kwargs['job_ticket_ref'] = json_data['ticket_ref']

        if 'start_at' in json_data:
            try:
                time_start = datetime.strptime(json_data['start_at'],
                                               date_format)
                time_now = datetime.utcnow()

                if time_start < time_now:
                    return empty_result(status='error',
                                        data='start_at must be in the future')
                time_diff = time_start - time_now
                seconds = int(time_diff.total_seconds())
            except Exception as e:
                logger.exception(f'Exception when scheduling job: {e}')
                return empty_result(status='error',
                                    data=f'Invalid date format, should be: {date_format}')

        scheduler = Scheduler()
        job_id = scheduler.add_onetime_job(
            'cnaas_nms.confpush.firmware:device_upgrade',
            when=seconds,
            scheduled_by=get_jwt_identity(),
            kwargs=kwargs)
        res = empty_result(data='Scheduled job to upgrade devices')
        res['job_id'] = job_id

        resp = make_response(json.dumps(res), 200)
        if total_count:
            resp.headers['X-Total-Count'] = total_count
        resp.headers['Content-Type'] = "application/json"
        return resp