| #!/usr/bin/env python2 |
| # Copyright 2019 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file |
| from __future__ import unicode_literals |
| from __future__ import print_function |
| |
| import collections |
| import datetime |
| import io |
| import json |
| import os |
| import subprocess |
| import tempfile |
| import time |
| import shutil |
| import sys |
| import types |
| import itertools |
| |
| import common |
| |
| _THIS_FILE = os.path.abspath(__file__) |
| _THIS_DIR = os.path.dirname(_THIS_FILE) |
| |
| _SKYLAB_EXE = 'skylab' |
| |
| __all__ = ['migrate', 'setup'] |
| |
| _TEMPPATH = object() |
| |
| _FAILED_STEP_SENTINEL = object() |
| |
| _LITERAL_MAP = { |
| 'True': True, |
| 'False': False, |
| 'None': None, |
| } |
| |
| |
| TEXT = (unicode, str) |
| |
| |
| def find_atest_path(): |
| """Get the path to the 'atest' executable. |
| |
| @return : path to 'atest' executable |
| """ |
| atest_exe = os.path.join(_THIS_DIR, 'atest') |
| assert os.path.exists(atest_exe) |
| return atest_exe |
| |
| |
| _ATEST_EXE = find_atest_path() |
| |
| |
| def strip_suffix(str, suffix): |
| if str.endswith(suffix): |
| return str[:-len(suffix)] |
| else: |
| return str |
| |
| |
| def call_with_tempfile(cmd, lines): |
| """Execute command requiring a temporary file and return a CommandOutput struct. |
| |
| @param cmd : the components of the argv to be executed. |
| The magical value _TEMPPATH will be replaced with the path |
| to the temporary file. |
| @param lines : the lines of content to write to the temporary file |
| |
| @returns : CommandOutput struct containing output as list of lines |
| and the exit status |
| """ |
| if isinstance(cmd, (str, unicode)): |
| raise TypeError('cmd cannot be str or unicode') |
| assert not isinstance(lines, (str, unicode)) |
| with tempfile.NamedTemporaryFile() as fh: |
| for line in lines: |
| fh.write(line) |
| if line.endswith('\n'): |
| pass |
| else: |
| fh.write('\n') |
| fh.flush() |
| assert os.path.exists(fh.name) |
| cmd = [(x if x is not _TEMPPATH else fh.name) for x in cmd] |
| try: |
| output = subprocess.check_output(cmd) |
| if isinstance(output, (bytes, unicode)): |
| output = output.splitlines() |
| return CommandOutput( |
| exit_code=0, output=[x.decode('utf-8') for x in output]) |
| except subprocess.CalledProcessError as e: |
| return CommandOutput( |
| exit_code=e.returncode, |
| output=[x.decode('utf-8') for x in e.output.splitlines()]) |
| |
| |
| |
| # accepts: string |
| # returns: string but with exactly one trailing newline |
| def _one_trailing_newline(s): |
| s = s.rstrip("\n") |
| return s + "\n" |
| |
| # accepts: shell command, rest of args |
| # returns: exit_status, stdout, stderr |
| def shell_capture_all(cmd, *rest): |
| shellcmd = ("bash", "-c", cmd, "bash",) + rest |
| pr = subprocess.Popen( |
| shellcmd, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| ) |
| stdout, stderr = pr.communicate() |
| return pr.returncode, stdout, stderr |
| |
| |
| # accepts: shell command, rest of args |
| # returns: exit_status, stdout, stderr |
| def shell_capture_all_no_stdin(cmd, *rest): |
| with open(os.devnull) as null: |
| shellcmd = ("bash", "-c", cmd, "bash",) + rest |
| pr = subprocess.Popen( |
| shellcmd, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| stdin=null, |
| ) |
| stdout, stderr = pr.communicate() |
| return pr.returncode, stdout, stderr |
| |
| |
| # accepts: shell command, lines of temporary file |
| # returns: exit_status, stdout, stderr |
| def shell_capture_all_with_tempfile(cmd, lines): |
| exit_status = stdout = stderr = None |
| if lines in (bytes, unicode): |
| raise TypeError("lines must not be text-like (%s)" % type(lines)) |
| filename = None |
| with tempfile.NamedTemporaryFile(delete=False) as fh: |
| filename = fh.name |
| for line in lines: |
| fh.write(_one_trailing_newline(line)) |
| try: |
| exit_status, stdout, stderr = shell_capture_all_no_stdin(cmd, filename) |
| finally: |
| os.unlink(filename) |
| return exit_status, stdout, stderr |
| |
| |
| CommandOutput = collections.namedtuple('CommandOutput', ['output', 'exit_code']) |
| |
| |
| def _nontrivially_pairwise_disjoint(*sets): |
| """If there are any items present in more than one set, then 'sets' is not pairwise disjoint. |
| |
| If there are exactly zero or one sets, then there are no pairs of sets |
| and therefore the pairwise disjoint condition will always hold |
| regardless of the set contents. Therefore, calling |
| _nontrivially_pairwise_disjoint |
| with fewer than 2 sets probably indicates a logic error and will result |
| in an exception being thrown. |
| |
| Example: [{1}, {2}, set(), {3, 4, 5}, set()] |
| CounterExample: [{1, 2}, {2, 3}] |
| |
| @param sets: a sequence of sets |
| @return: whether the sets are pairwise disjoint |
| """ |
| if len(sets) in (0, 1): |
| raise ValueError( |
| 'a collection of 0 or 1 sets is trivially pairwise disjoint.') |
| combined = set() |
| sum_len_set = 0 |
| for set_ in sets: |
| combined.update(set_) |
| sum_len_set += len(set_) |
| assert len(combined) <= sum_len_set |
| return len(combined) == sum_len_set |
| |
| |
| MigrateDutCommandStatus = collections.namedtuple('MigrateDutCommandStatus', [ |
| 'success', 'failure', 'needs_add_to_skylab', 'needs_drone', 'needs_rename' |
| ]) |
| |
| AddToSkylabInventoryAndDroneStatus = collections.namedtuple( |
| 'AddToSkylabInventoryAndDroneStatus', |
| ['complete', 'without_drone', 'not_started']) |
| |
| RenameCommandStatus = collections.namedtuple('RenameCommandStatus', |
| ['renamed', 'not_renamed']) |
| |
| LockCommandStatus = collections.namedtuple('LockCommandStatus', |
| ['locked', 'not_locked', 'tries']) |
| |
| MigrationPlan = collections.namedtuple('MigrationPlan', ['transfer', 'retain']) |
| |
| |
| class MigrationException(Exception): |
| """Raised when migration fails""" |
| pass |
| |
| |
| def stderr_log(*args, **kwargs): |
| return print(*args, file=sys.stderr, **kwargs) |
| |
| |
| def _humantime(): |
| return tuple(datetime.datetime.now().timetuple())[:6] |
| |
| |
| def _migration_json_summary(failed_step=_FAILED_STEP_SENTINEL, |
| plan=None, |
| not_locked=None, |
| migrate_status=None, |
| unconditionally_migrate_status=None): |
| assert isinstance(plan, MigrationPlan) |
| assert not isinstance(not_locked, (str, unicode)) |
| assert isinstance(failed_step, (types.NoneType, unicode)) |
| assert isinstance(migrate_status, (types.NoneType, MigrateDutCommandStatus)) |
| assert isinstance(unconditionally_migrate_status, MigrateDutCommandStatus) |
| |
| def merge_attrs(fieldname, struct1, struct2=None): |
| merged = set() |
| if struct1: |
| merged.update(getattr(struct1, fieldname)) |
| if struct2: |
| merged.update(getattr(struct2, fieldname)) |
| return sorted(merged) |
| |
| |
| out = { |
| 'locked_success': (failed_step is None), |
| 'failed_step': failed_step, |
| 'plan': { |
| 'transfer': merge_attrs('transfer', plan), |
| 'retain': merge_attrs('retain', plan), |
| }, |
| 'duts': { |
| 'migrated': |
| merge_attrs('success', migrate_status, unconditionally_migrate_status), |
| 'not_locked': |
| list(sorted(set(not_locked))), |
| 'needs_add_to_skylab': |
| merge_attrs('needs_add_to_skylab', migrate_status, unconditionally_migrate_status), |
| 'needs_drone': |
| merge_attrs('needs_drone', migrate_status, unconditionally_migrate_status), |
| 'needs_rename': |
| merge_attrs('needs_rename', migrate_status, unconditionally_migrate_status), |
| } |
| } |
| return out |
| |
| |
| class AtestCmd(object): |
| """Helper functions for executing 'atest' commands""" |
| |
| @staticmethod |
| def brief_info_cmd(): |
| """Command line for getting per-host info. |
| |
| @return : list of strings to be executed as external command |
| """ |
| return [_ATEST_EXE, 'host', 'list', '--parse', '-M', _TEMPPATH] |
| |
| @staticmethod |
| def brief_info(hostnames=None): |
| """Run brief info command. |
| |
| @return : iterator of dictionaries describing each hostname |
| """ |
| hostnames = hostnames or set() |
| items = call_with_tempfile(AtestCmd.brief_info_cmd(), hostnames).output |
| for item in AtestCmd.brief_info_filter(items): |
| yield item |
| |
| @staticmethod |
| def brief_info_filter(stream): |
| """Filter lines of output from 'atest host list...'. |
| |
| @return : iterator of fields |
| """ |
| for line in stream: |
| line = line.rstrip() |
| if line: |
| fields = line.split('|') |
| # if the line of output has exactly zero or one |
| # |-delimited sections, then it is not a description |
| # of a DUT. Silently discard such lines. |
| if len(fields) in (0, 1): |
| continue |
| # trim labels entry if it exists |
| if fields[-1].startswith('Labels='): |
| fields.pop() |
| d = {} |
| for f in fields: |
| k, _, v = f.partition('=') |
| # if the value associated with a key is a Python literal |
| # such as True, False, or None, replace it with the |
| # corresponding Python value. |
| # otherwise, use the original string. |
| d[k] = _LITERAL_MAP.get(v, v) |
| yield d |
| |
| @staticmethod |
| def rename_cmd(for_migration=True): |
| """Generate command line arguments for 'rename'. |
| |
| @return : command line arguments |
| """ |
| name_flag = '--for-migration' if for_migration else '--for-rollback' |
| return [ |
| _ATEST_EXE, 'host', 'rename', '--non-interactive', name_flag, |
| '--parse', '-M', _TEMPPATH |
| ] |
| |
| @staticmethod |
| def rename(hostnames=None, for_migration=True): |
| """Rename a list of hosts. |
| |
| @return : iterator of successfully renamed hosts |
| """ |
| hostnames = hostnames or set() |
| |
| to_migrate_hostnames = set() |
| already_migrated_hostnames = set() |
| |
| for hostname in hostnames: |
| if hostname.endswith("-migrated-do-not-use"): |
| already_migrated_hostnames.add(hostname) |
| else: |
| to_migrate_hostnames.add(hostname) |
| |
| stderr_log('begin rename', time.time(), _humantime()) |
| items = call_with_tempfile( |
| AtestCmd.rename_cmd(for_migration=for_migration), |
| lines=to_migrate_hostnames).output |
| |
| out = list(AtestCmd.rename_filter(items)) |
| out_seen = set(out) |
| |
| # out and already_migrated_hostnames should be disjoint |
| # but if they aren't we still don't want to list the same |
| # hostname twice |
| for hostname in already_migrated_hostnames: |
| if hostname not in out_seen: |
| out.append(hostname) |
| |
| stderr_log('end rename', time.time(), _humantime()) |
| return out |
| |
| @staticmethod |
| def rename_filter(stream): |
| """Process each item of output from `atest host rename...`. |
| |
| @return : iterator of successfully renamed hosts |
| """ |
| for item in stream: |
| row = [x.strip() for x in item.strip().split()] |
| if len(row) == 3: |
| src, sep, dest = row |
| # dest has the 'migrated-do-not-use' suffix |
| # use src! |
| if sep != 'to': |
| continue |
| yield src |
| |
| @staticmethod |
| def statjson_cmd(hostname=None): |
| """Command line for generating json for hostname. |
| |
| @return : command line |
| """ |
| return [_ATEST_EXE, 'host', 'statjson', '--', hostname] |
| |
| @staticmethod |
| def statjson(hostname=None): |
| """Run the command for getting the host json. |
| |
| @return : 'atest host statjson' output as parsed json. |
| """ |
| cmd = AtestCmd.statjson_cmd(hostname=hostname) |
| (out, err, exit_status) = capture_all(cmd) |
| if exit_status == 0: |
| try: |
| return json.loads(out.decode('utf-8')) |
| except ValueError: |
| sys.stderr.write(out) |
| sys.stderr.write("\n\n") |
| return None |
| else: |
| if exit_status: |
| if "Failed to stat:" in err: |
| assert "Unknown host" in err |
| return None |
| else: |
| assert "unexpected failure" |
| |
| @staticmethod |
| def atest_lock_cmd(reason=None): |
| """Generate command for 'atest host mod --lock'. |
| |
| @return : command line |
| """ |
| return [ |
| _ATEST_EXE, 'host', 'mod', '--lock', '-r', reason, '-M', _TEMPPATH |
| ] |
| |
| @staticmethod |
| def atest_lock(reason=None, hostnames=None): |
| """Try to lock hostnames via 'atest host mod --lock'. |
| |
| @return : Nothing |
| """ |
| hostnames = hostnames or set() |
| assert isinstance(reason, unicode) |
| cmd = AtestCmd.atest_lock_cmd(reason=reason) |
| # NOTE: attempting to lock a host can fail because the host |
| # is already locked. Therefore, atest_lock always succeeds |
| # regardless of the exit status of the command. |
| call_with_tempfile(cmd, hostnames) |
| |
| @staticmethod |
| def atest_lock_filter(stream): |
| """Take lines from 'atest host mod --lock' and emit a stream of hostnames. |
| |
| The first line "Locked hosts:" is removed. We trim the whitespace of the |
| other lines. |
| |
| Input: |
| Locked Hosts: |
| A |
| B |
| C |
| |
| Output: |
| A |
| B |
| C |
| """ |
| for x in stream: |
| if x.lower().startswith('locked host'): |
| continue |
| else: |
| yield x.strip() |
| |
| @staticmethod |
| def atest_unlock_cmd(): |
| """Generate command for 'atest host mod --unlock'.""" |
| return [_ATEST_EXE, 'host', 'mod', '--unlock', '-M', _TEMPPATH] |
| |
| @staticmethod |
| def atest_unlock(reason=None, hostnames=None): |
| """Unlock hostnames via 'atest host mod --unlock'. |
| |
| @return : iterator of successfully unlocked hosts |
| """ |
| hostnames = hostnames or set() |
| cmd = AtestCmd.atest_unlock_cmd() |
| items = call_with_tempfile(cmd, hostnames).output |
| for item in AtestCmd.atest_unlock_filter(items): |
| yield item |
| |
| @staticmethod |
| def atest_unlock_filter(stream): |
| """Take lines from 'atest host mod --unlock' and emit a stream of hostnames. |
| |
| The first line "Unlocked hosts:" is removed. We trim the whitespace of |
| the other lines. |
| |
| Input: |
| Unlocked Hosts: |
| A |
| B |
| C |
| |
| Output: |
| A |
| B |
| C |
| """ |
| for x in stream: |
| if x.lower().startswith('unlocked host'): |
| continue |
| else: |
| yield x.strip() |
| |
| @staticmethod |
| def atest_get_migration_plan_cmd(ratio): |
| """Generate command for 'atest host get_migration_plan --mlist ...'""" |
| return [ |
| _ATEST_EXE, 'host', 'get_migration_plan', '--ratio', |
| unicode(ratio), '--mlist', _TEMPPATH |
| ] |
| |
| @staticmethod |
| def atest_get_migration_plan(ratio, hostnames=[]): |
| # optimizations in case the ratio is 1 or 0 |
| hostnames = hostnames or set() |
| if ratio == 0: |
| return { |
| 'transfer': [], |
| 'retain': hostnames, |
| } |
| if ratio == 1: |
| return { |
| 'transfer': hostnames, |
| 'retain': [], |
| } |
| cmd = AtestCmd.atest_get_migration_plan_cmd(ratio) |
| output = call_with_tempfile(cmd, hostnames).output |
| out = json.loads(''.join(output)) |
| return out |
| |
| |
| def trywith(exn_type, f, *args, **kwargs): |
| out = None |
| exn = None |
| try: |
| out = f(*args, **kwargs) |
| except exn_type as e: |
| exn = e |
| return (out, exn) |
| |
| |
| |
| def backtick(*args, **kwargs): |
| output = None |
| exit_status = None |
| out, exn = trywith(subprocess.CalledProcessError, subprocess.check_output, *args, **kwargs) |
| if exn is None: |
| output = out |
| exit_status = 0 |
| else: |
| output = exn.output |
| exit_status = exn.returncode |
| return (output, exit_status) |
| |
| |
| def capture_all(*args, **kwargs): |
| proc = subprocess.Popen( |
| *args, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| **kwargs |
| ) |
| out, err = proc.communicate() |
| return (out, err, proc.returncode) |
| |
| |
| |
| # accepts: iterable of hostnames |
| # returns: { |
| # good: hostnames in autotest |
| # bad: hostnames not in autotest |
| # } |
| def autotest_status(hostnames): |
| os.environ["ATEST"] = _ATEST_EXE |
| status, out, err = shell_capture_all_with_tempfile('"${ATEST}" host list --hostnames-only --mlist "$1"', hostnames) |
| good = [] |
| bad = [] |
| # process the bad lines |
| for errline in err: |
| # skip preamble |
| if errline.startswith("Unknown host"): |
| continue |
| bad.append(errline.strip()) |
| # process the good lines |
| for goodline in good: |
| good.append(good.strip()) |
| return { |
| "good": good, |
| "bad": bad |
| } |
| |
| # accepts: iterable of hostnames |
| # returns: { |
| # good: hostnames in skylab |
| # bad: hostnames not in skylab |
| # } |
| def skylab_status(hostnames): |
| os.environ["SKYLAB"] = _SKYLAB_EXE |
| good = [] |
| bad = [] |
| for hostname in hostnames: |
| os.environ["HOSTNAME"] = hostname |
| status, out, err = shell_capture_all_no_stdin('"${SKYLAB}" dut-info "${HOSTNAME}"') |
| # TODO(gregorynisbet): make error checking more robust here to see why exactly |
| # we couldn't get info on the DUT. |
| if status == 0: |
| good.append(hostname) |
| else: |
| bad.append(hostname) |
| return { |
| "good": good, |
| "bad": bad, |
| } |
| |
| |
| |
| # accepts: iterable of hostnames |
| # returns: { |
| # good: hostnames with no issues |
| # not_renamed: hostnames in skylab but not renamed in autotest |
| # not_in_skylab: hostnames that are not in skylab |
| # not_renamed_not_in_skylab: hostnames that aren't renamed or in skylab |
| # } |
| def hostname_migrated_status(hostnames): |
| migrated_map = {} |
| for hostname in hostnames: |
| migrated_map[hostname + "-migrated-do-not-use"] = hostname |
| |
| atest_out = autotest_status(hostnames) |
| atest_out_good = set(atest_out["good"]) |
| atest_out_bad = set(atest_out["bad"]) |
| |
| skylab_out = skylab_status(hostnames) |
| skylab_out_good = set(skylab_out["good"]) |
| skylab_out_bad = set(skylab_out["bad"]) |
| |
| atest_renamed_out = autotest_status(list(migrated_map)) |
| atest_renamed_out_good = set(atest_renamed_out["good"]) |
| atest_renamed_out_bad = set(atest_renamed_out["bad"]) |
| |
| good = [] |
| not_renamed = [] |
| not_in_skylab = [] |
| not_renamed_not_in_skylab = [] |
| |
| for hostname in hostnames: |
| # hostname flags is a string listing the undesirable properties |
| # associated with that particular hostname |
| # A -- host has bad autotest status, either not renamed or old hostname present |
| # S -- not migrated to skylab |
| hostname_flags = set() |
| if hostname in atest_out_good: |
| hostname_flags.add("A") |
| if hostname in atest_renamed_out_bad: |
| hostname_flags.add("A") |
| if hostname in skylab_out_bad: |
| hostname_flags.add("S") |
| |
| if hostname_flags == set(): |
| good.append(hostname) |
| elif hostname_flags == {"A"}: |
| not_renamed.append(hostname) |
| elif hostname_flags == {"S"}: |
| not_in_skylab.append(hostname) |
| elif hostname_flags == {"A", "S"}: |
| not_renamed_not_in_skylab.append(hostname) |
| else: |
| assert False, ("impossible, unexpected set %s" % hostname_flags) |
| |
| return { |
| "good": good, |
| "not_renamed": not_renamed, |
| "not_in_skylab": not_in_skylab, |
| "not_renamed_not_in_skylab": not_renamed_not_in_skylab, |
| } |
| |
| |
| class SkylabCmd(object): |
| """Helper functions for executing Skylab commands""" |
| |
| ADD_MANY_DUTS_CMD = (_SKYLAB_EXE, 'quick-add-duts') |
| |
| @staticmethod |
| def add_one_dut_cmd(): |
| """Create the skylab command line invocation for adding a single DUT.""" |
| return [ |
| _SKYLAB_EXE, |
| 'add-dut', |
| '-skip-image-download', |
| '-skip-install-firmware', |
| '-skip-install-os', |
| '-specs-file', |
| _TEMPPATH, |
| ] |
| |
| @staticmethod |
| def add_one_dut(add_dut_content): |
| """Add one dut to skylab.""" |
| stderr_log('begin add_one_dut', time.time(), _humantime()) |
| cmd = SkylabCmd.add_one_dut_cmd() |
| out = call_with_tempfile(cmd, [json.dumps(add_dut_content)]) |
| stderr_log('end add_one_dut', time.time(), _humantime()) |
| return out |
| |
| @staticmethod |
| def assign_one_dut_cmd(hostname=None): |
| """Command line for assigning a single DUT to a randomly chosen drone.""" |
| # by default, skylab assign-dut will pick a random drone |
| return [_SKYLAB_EXE, 'assign-dut', '--', hostname] |
| |
| @staticmethod |
| def add_many_duts(dut_contents): |
| stderr_log('begin add_many_duts', time.time(), _humantime()) |
| for dut_content in dut_contents: |
| stderr_log("add many DUTs: ", str(dut_content)[:80] + "...") |
| """Add multiple DUTs to skylab at once. |
| |
| @param dut_contents: a sequence of JSON-like objects describing DUTs as |
| used by `skylab add-dut` and `skylab quick-add-dut` |
| |
| @returns : nothing |
| """ |
| # TODO(gregorynisbet) -- how fine-grained does the error reporting need |
| # to be? is it possible for some duts to be |
| # successfully migrated and others not? |
| # The action performed by `skylab quick-add-duts` |
| # is idempotent, so trying multiple times is not |
| # necessarily a problem. |
| td = tempfile.mkdtemp() |
| try: |
| paths = [] |
| for i in range(len(dut_contents)): |
| path_ = os.path.join(td, str(i)) |
| with open(path_, 'w') as fh: |
| json.dump(dut_contents[i], fh) |
| paths.append(path_) |
| cmd = list(SkylabCmd.ADD_MANY_DUTS_CMD) + paths |
| print("log command") |
| stderr_log(cmd) |
| print("capture_all") |
| # ignore cases where the hostname doesn't exist |
| (out, err, exit_status) = capture_all(cmd) |
| if exit_status != 0: |
| if "Failed to stat:" in err: |
| assert "Unknown host" in err |
| # then do nothing |
| |
| # shutil.rmtree(td, ignore_errors=True) |
| finally: |
| stderr_log('end add_many_duts', time.time(), _humantime()) |
| |
| |
| class Migration(object): |
| |
| @staticmethod |
| def migration_plan(ratio, hostnames=None): |
| hostnames = hostnames or set() |
| plan = AtestCmd.atest_get_migration_plan( |
| ratio=ratio, hostnames=hostnames) |
| return MigrationPlan(transfer=plan['transfer'], retain=plan['retain']) |
| |
| @staticmethod |
| def lock(hostnames=None, reason=None, retries=3): |
| """Lock a list of hostnames with retries. |
| """ |
| hostnames = hostnames or set() |
| assert isinstance(reason, unicode) |
| to_lock = set(hostnames) |
| for _ in range(retries): |
| AtestCmd.atest_lock(hostnames=to_lock.copy(), reason=reason) |
| |
| @staticmethod |
| def ensure_lock(hostnames=None): |
| """Without changing the state of a DUT, determine which are locked. |
| |
| @return : LockCommandStatus |
| """ |
| hostnames = hostnames or set() |
| dut_infos = AtestCmd.brief_info(hostnames=hostnames) |
| all_hosts = set(hostnames) |
| confirmed_locked = set() |
| for dut_info in dut_infos: |
| locked = dut_info['Locked'] |
| assert locked in (True, False) |
| if locked: |
| confirmed_locked.add(dut_info['Host']) |
| return LockCommandStatus( |
| locked=confirmed_locked, |
| not_locked=(all_hosts - confirmed_locked), |
| tries=None, |
| ) |
| |
| @staticmethod |
| def rename(hostnames=None, for_migration=True, retries=1): |
| """Rename a list of hosts with retry. |
| |
| @return : {"renamed": renamed hosts, "not-renamed": not renamed |
| hosts} |
| """ |
| hostnames = hostnames or set() |
| all_hosts = set(hostnames) |
| needs_rename = all_hosts.copy() |
| for _ in range(retries): |
| for successfully_renamed in AtestCmd.rename( |
| hostnames=needs_rename.copy(), for_migration=for_migration): |
| needs_rename.discard(successfully_renamed) |
| out = RenameCommandStatus( |
| renamed=(all_hosts - needs_rename), |
| not_renamed=needs_rename, |
| ) |
| return out |
| |
| @staticmethod |
| def add_to_skylab_inventory_and_drone(use_quick_add, hostnames=None, rename_retries=3): |
| """@returns : AddToSkylabInventoryAndDroneStatus""" |
| hostnames = hostnames or set() |
| assert not isinstance(hostnames, (unicode, bytes)) |
| stderr_log('begin add hostnames to inventory', time.time(), |
| _humantime()) |
| all_hosts = set(hostnames) |
| moved = set() |
| with_drone = set() |
| |
| if use_quick_add: |
| stderr_log("quick add path", time.time(), _humantime()) |
| dut_contents = [] |
| good_hostnames = [] |
| for hostname in hostnames: |
| out_json = dut_contents.append(AtestCmd.statjson(hostname=hostname)) |
| if out_json is None: |
| pass |
| else: |
| good_hostnames.append(out_json) |
| # SkylabCmd.add_many_duts does not check for whether the action was successful |
| # we use hostname_migrated_status to check whether the duts we were supposed to migrate |
| # were actually migrated |
| SkylabCmd.add_many_duts(dut_contents=dut_contents) |
| |
| # strip the migrated suffix when checking the status of each of the hostnames |
| truncated_hostnames = [strip_suffix(hostname, "-migrated-do-not-use") for hostname in hostnames] |
| status_out = hostname_migrated_status(truncated_hostnames) |
| |
| # anything in the good state or missing rename is fine |
| complete = status_out["good"] + status_out["not_renamed"] |
| # anything where the status indicates that the entity is not in skylab yet is not fine |
| not_started = status_out["not_in_skylab"] + status_out["not_renamed_not_in_skylab"] |
| |
| return AddToSkylabInventoryAndDroneStatus( |
| complete=complete, |
| without_drone=set(), |
| not_started=not_started, |
| ) |
| |
| else: |
| stderr_log("slow add path", time.time(), _humantime()) |
| for hostname in hostnames: |
| if hostname not in moved: |
| skylab_dut_descr = AtestCmd.statjson(hostname=hostname) |
| stderr_log("processing hostname", hostname) |
| status = SkylabCmd.add_one_dut(add_dut_content=skylab_dut_descr) |
| if status.exit_code != 0: |
| continue |
| moved.add(hostname) |
| with_drone.add(hostname) |
| |
| out = AddToSkylabInventoryAndDroneStatus( |
| complete=with_drone, |
| without_drone=(moved - with_drone), |
| not_started=((all_hosts - moved) - with_drone), |
| ) |
| stderr_log('end add hostnames to inventory', time.time(), _humantime()) |
| return out |
| |
| @staticmethod |
| def migrate_known_good_duts_until_max_duration_sync(use_quick_add, |
| hostnames=None, |
| max_duration=60 * 60, |
| min_ready_intervals=10, |
| interval_len=0): |
| """Take a list of DUTs and attempt to migrate them when they aren't busy. |
| |
| @param hostnames : list of hostnames |
| @param max_duration : when to stop trying to safely migrate duts |
| @param min_ready_intervals : the minimum number of intervals that a DUT |
| must have a good status |
| @param interval_len : the length in seconds of interval |
| @param use_quick_add : whether to use skylab quick-add-duts. |
| |
| @returns : {"success": successfuly migrated DUTS, "failure": |
| non-migrated DUTS} |
| """ |
| hostnames = hostnames or set() |
| assert interval_len is not None |
| stderr_log('begin migrating only ready DUTs', time.time(), _humantime()) |
| start = time.time() |
| stop = start + max_duration |
| good_intervals = collections.Counter() |
| need_to_move = set(hostnames) |
| successfully_moved = set() |
| needs_add_to_skylab = set() |
| needs_drone = set() |
| needs_rename = set() |
| while time.time() < stop: |
| if not need_to_move: |
| break |
| ready_to_move = set() |
| # determine which duts have been in a good state for min_ready_intervals |
| for record in AtestCmd.brief_info(hostnames=need_to_move.copy()): |
| hostname = record['Host'] |
| if record['Status'] not in {'Running', 'Provisioning'}: |
| good_intervals[hostname] += 1 |
| else: |
| del good_intervals[hostname] |
| if good_intervals[hostname] >= min_ready_intervals: |
| ready_to_move.add(hostname) |
| need_to_move.discard(hostname) |
| # move the ready to move duts now |
| # any dut that is declared ready to move at this point will definitely |
| # reach a terminal state |
| skylab_summary = Migration.add_to_skylab_inventory_and_drone( |
| hostnames=ready_to_move, use_quick_add=use_quick_add) |
| needs_add_to_skylab.update(skylab_summary.not_started) |
| needs_drone.update(skylab_summary.without_drone) |
| # rename the autotest entry all at once |
| rename_summary = Migration.rename( |
| hostnames=skylab_summary.complete, for_migration=True) |
| needs_rename.update(rename_summary.not_renamed) |
| successfully_moved.update(rename_summary.renamed) |
| time.sleep(interval_len) |
| out = MigrateDutCommandStatus( |
| success=successfully_moved, |
| failure=(need_to_move | needs_add_to_skylab | needs_drone |
| | needs_rename), |
| needs_add_to_skylab=needs_add_to_skylab, |
| needs_drone=needs_drone, |
| needs_rename=needs_rename, |
| ) |
| stderr_log('end migrating only ready DUTs', time.time(), _humantime()) |
| return out |
| |
| @staticmethod |
| def migrate_duts_unconditionally(hostnames, use_quick_add): |
| """regardless of the DUTs' status, forcibly migrate all the DUTs to skylab. |
| |
| @returns: MigrateDutCommandStatus |
| """ |
| hostnames = hostnames or set() |
| assert not isinstance(hostnames, (unicode, bytes)) |
| stderr_log('begin unconditional migration', time.time(), _humantime()) |
| successfully_moved = set() |
| needs_add_to_skylab = set() |
| needs_drone = set() |
| needs_rename = set() |
| skylab_summary = Migration.add_to_skylab_inventory_and_drone( |
| hostnames=hostnames, use_quick_add=use_quick_add) |
| needs_add_to_skylab.update(skylab_summary.not_started) |
| needs_drone.update(skylab_summary.without_drone) |
| rename_summary = Migration.rename( |
| hostnames=skylab_summary.complete, for_migration=True) |
| successfully_moved.update(rename_summary.renamed) |
| needs_rename.update(rename_summary.not_renamed) |
| needs_rename.discard(rename_summary.not_renamed) |
| out = MigrateDutCommandStatus( |
| success=successfully_moved, |
| failure=(needs_drone | needs_rename | needs_add_to_skylab), |
| needs_add_to_skylab=needs_add_to_skylab, |
| needs_drone=needs_drone, |
| needs_rename=needs_rename, |
| ) |
| stderr_log('end unconditional migration', time.time(), _humantime()) |
| return out |
| |
| @staticmethod |
| def migrate(hostnames=None, |
| ratio=1, |
| reason=None, |
| max_duration=None, |
| interval_len=None, |
| min_ready_intervals=10, |
| immediately=None, |
| use_quick_add=False): |
| """Migrate duts from autotest to skylab. |
| |
| @param ratio : ratio of DUTs in hostnames to migrate. |
| @param hostnames : hostnames to migrate |
| @param reason : the reason to give for providing the migration |
| @param interval_len : length of time between checks for DUT readiness |
| @param max_duration : the grace period to allow DUTs to finish their |
| tasks |
| @param min_ready_intervals : minimum number of intervals before a device |
| is healthy |
| |
| @return : nothing |
| """ |
| hostnames = hostnames or set() |
| assert isinstance(reason, (unicode, bytes)) |
| assert interval_len is not None |
| assert max_duration is not None |
| assert immediately is not None |
| reason = reason if isinstance(reason, |
| unicode) else reason.decode('utf-8') |
| # log the parameters of the migration |
| stderr_log('begin migrate', time.time(), _humantime()) |
| stderr_log('number of hostnames', len(hostnames), time.time(), _humantime()) |
| stderr_log('ratio', ratio, time.time(), _humantime()) |
| stderr_log('max_duration', max_duration, time.time(), _humantime()) |
| stderr_log('atest', _ATEST_EXE, time.time(), _humantime()) |
| stderr_log('skylab', _SKYLAB_EXE, time.time(), _humantime()) |
| stderr_log('minimum number of intervals', min_ready_intervals, time.time(), _humantime()) |
| stderr_log('immediately', immediately, time.time(), _humantime()) |
| stderr_log('use_quick_add', use_quick_add, time.time(), _humantime()) |
| |
| all_hosts = tuple(hostnames) |
| plan = Migration.migration_plan(ratio=ratio, hostnames=all_hosts) |
| Migration.lock(hostnames=plan.transfer, reason=reason) |
| failed_step = _FAILED_STEP_SENTINEL |
| ensure_lock_status = Migration.ensure_lock(hostnames=plan.transfer) |
| if ensure_lock_status.not_locked: |
| failed_step = 'lock' |
| to_migrate = plan.transfer |
| migrate_status = None |
| if not immediately: |
| migrate_status = \ |
| Migration.migrate_known_good_duts_until_max_duration_sync( |
| hostnames=to_migrate, |
| max_duration=max_duration, |
| min_ready_intervals=min_ready_intervals, |
| interval_len=interval_len, |
| use_quick_add=use_quick_add) |
| to_migrate = migrate_status.failure |
| unconditionally_migrate_status = Migration.migrate_duts_unconditionally( |
| use_quick_add=use_quick_add, |
| hostnames=to_migrate, |
| ) |
| failed_step = None |
| out = _migration_json_summary( |
| failed_step=failed_step, |
| plan=plan, |
| not_locked=ensure_lock_status.not_locked, |
| migrate_status=migrate_status, |
| unconditionally_migrate_status=unconditionally_migrate_status, |
| ) |
| stderr_log('end migrate', time.time(), _humantime()) |
| return out |
| |
| |
| |
| # accepts: iterable |
| # returns: item or None, ok (true if item is real, false otherwise) |
| def next_safe(it): |
| it = iter(it) |
| try: |
| return next(it), True |
| except StopIteration: |
| return None, False |
| |
| |
| # accepts: n (stride length), it (iterable) |
| # returns: iterator of arrays of n items each |
| def natatime(n, it): |
| it = iter(it) |
| while True: |
| out = [] |
| for i in range(n): |
| item, ok = next_safe(it) |
| if ok: |
| out.append(item) |
| else: |
| break |
| if len(out): |
| yield out |
| else: |
| return |
| |
| |
| |
| |
| def migrate(batch_size=None, hostnames=None, **kwargs): |
| if batch_size is None: |
| return Migration.migrate(hostnames=hostnames, **kwargs) |
| if batch_size is not None: |
| it = natatime(n=batch_size, it=hostnames) |
| out = [] |
| for batch in it: |
| res = Migration.migrate(hostnames=hostnames, **kwargs) |
| json.dumps(res, sys.stderr, indent=4) |
| out.append(res) |
| return out |
| |
| |
| def setup(atest_exe=None, skylab_exe=None): |
| """Configure the module-scoped path to atest and skylab executables.""" |
| if atest_exe is not None: |
| _ATEST_EXE = atest_exe |
| if skylab_exe is not None: |
| _SKYLAB_EXE = skylab_exe |