blob: eb927c64195ea508e2e7e49180f4a90439b1163c [file] [log] [blame]
#!/usr/bin/env python2
# user facing commands:
#
# -- write_statjson_hostnames
# -- do_quick_add
# pretty_dump_board:
# Given the name of a board, dump it to a directory with
# the following structure:
# data.dir
# - hostname1
# - hostname2
# - hostname3
# NEXT, report all the ways in which hostnames of that board are not valid
# jsonified protobufs for adding a new DUT.
#
# this involves shelling out to skylab validate-new-dut-json
# adding a dut that's already present
# $ skylab quick-add-dut /tmp/json
# Deployment ID: d604b46b-87c7-4a4d-8b8a-ed97565c4797
# Status: DUT_DEPLOYMENT_STATUS_FAILED
# Inventory change URL:
# Deploy task URL:
# Message: failed to add DUT(s) to fleet: add dut to fleet: inventory store commit: nothing to commit
# adding a dut successfully
# $ skylab quick-add-dut /tmp/json
# Deployment ID: 8e27b3ff-39ad-4b92-9bea-3f804a9d7bf9
# Status: DUT_DEPLOYMENT_STATUS_FAILED
# Inventory change URL: https://chrome-internal-review.googlesource.com/c/chromeos/infra_internal/skylab_inventory/+/1940714
# Deploy task URL:
# Message: missing deploy task ID in deploy request entry
# $ atest host rename --for-migration --non-interactive chromeos2-row1-rack7-host1
# Successfully renamed:
# chromeos2-row1-rack7-host1 to chromeos2-row1-rack7-host1-migrated-do-not-use
# unsuccessful lock
# $ atest host mod --lock -r 'migration to skylab' chromeos2-row1-rack7-host1-migrated-do-not-use
# Operation modify_host failed:
# ValidationError: {'locked': u'Host chromeos2-row1-rack7-host1-migrated-do-not-use already locked by pprabhu on 2019-08-29 16:48:51.'}
# 1
# successful lock
from __future__ import print_function
from __future__ import unicode_literals
import os
import sys
import subprocess
import pipes
import os.path
import warnings
import json
import tempfile
import shutil
TEXT = (type(b""), type(u""))
NONETYPE = type(None)
def flush_sync(fh):
fh.flush()
os.fsync(fh)
return
# 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: name of board
# returns: list of hostnames, error message (None if no error)
def get_all_hosts_for_board(board):
# extract just the hostnames for all the hosts in the board
cmd = "set -o pipefail; ( atest host list --label=%%%LABEL%%% | awk '{print $1}' )"
cmd = cmd.replace("%%%LABEL%%%", pipes.quote("board:%s" % board))
returncode, out, err = shell_capture_all(cmd)
if returncode != 0:
return None, "get_all_hosts_for_board: " + err
hostnames = [x.strip() for x in out.split()]
if hostnames and hostnames[0] == "Host":
hostnames.pop(0)
return hostnames, None
# make a directory and test if we can write to it as defensively as possible
# free vars: %%%DEST%%%
MAKE_DIR_CMD = r"""
mkdir -p %%%DEST%%%
cd %%%DEST%%% && touch ./writeable && rm ./writeable
"""
# free vars: %%%HOSTNAME%%% %%%DEST%%%
STATJSON_CMD = r"""
atest host statjson %%%HOSTNAME%%% 1>%%%DEST%%%/%%%HOSTNAME%%%
"""
# accepts: path to directory
# returns: error message (None if successfully made dir)
def mkdirp(dirpath):
cmd = MAKE_DIR_CMD.replace('%%%DEST%%%', pipes.quote(dirpath))
returncode, out, err = shell_capture_all(cmd)
if returncode == 0:
return None
else:
return err
# accepts: list of hostnames, output directory (path)
# returns: successful hosts, failed hosts, error message (None if no error)
def write_statjson_hostnames(hostnames, outdir):
successful_hosts = []
failed_hosts = []
err = mkdirp(outdir)
if err is not None:
return None, None, err
for hostname in hostnames:
cmd = STATJSON_CMD
cmd = cmd.replace('%%%HOSTNAME%%%', pipes.quote(hostname))
cmd = cmd.replace('%%%DEST%%%', pipes.quote(outdir))
returncode, out, err = shell_capture_all(cmd)
if returncode == 0:
successful_hosts.append(hostname)
else:
failed_hosts.append(hostname)
return successful_hosts, failed_hosts, None
# accepts: board name, output directory
# returns: successful hosts, failed hosts, error message
def write_statjson_board(board, outdir):
hostnames, err = get_all_hosts_for_board(board)
if err is not None:
return None, None, err
successful_hosts, failed_hosts, err = write_statjson_hostnames(hostnames, outdir)
return successful_hosts, failed_hosts, err
# free vars: %%%PATH%%%
VALIDATE_CMD = r"""
skylab validate-new-dut-json %%%PATH%%%
"""
# accepts: directory with hostname files
# returns: dictionary of the form below, error message (or None if no error)
#
# {
# hostname: error message (or None if no error)
# }
def validate_output(hostname_dir):
try:
paths = os.listdir(hostname_dir)
except OSError:
return None, ("bad directory: %s" % hostname_dir)
# defensively populate result dictionary with errors
# so that we don't erroneously conclude that an unvisited
# hostname was validated
result = {}
for path in paths:
result[path] = "DID NOT PROCESS"
for path in paths:
cmd = VALIDATE_CMD
cmd = cmd.replace('%%%PATH%%%', pipes.quote(os.path.join(hostname_dir, path)))
returncode, out, err = shell_capture_all(cmd)
if returncode != 0:
result[path] = "failed to validate (errcode %s): %s" % (returncode, err)
elif os.path.exists(os.path.join(hostname_dir, path)):
result[path] = None
else:
warnings.warn("nonexistent path %s" % path)
result[path] = "file does not exist"
return result, None
# accepts: name of board, output directory
# returns: dictionary of form below, error message (or None if no error)
#
# {
# hostname: error-message or NOFILE if no file or None if no error
# }
def process_board(board, output_dir):
queried_hosts, unqueried_hosts, err = write_statjson_board(board, output_dir)
if err is not None:
return None, err
validate_result, err = validate_output(output_dir)
if err is not None:
return None, err
result = {}
for hostname in queried_hosts:
try:
result[hostname] = validate_result[hostname]
except KeyError:
warnings.warn("hostname (%s) not present in validate_result")
for hostname in unqueried_hosts:
result["hostname"] = "NOFILE"
return result, None
# accepts: name of board, output directory
# returns: number of bad hosts
# emits: prints error message for every bad host
def pretty_process_board(board, output_dir):
result, err = process_board(board, output_dir)
if err is not None:
print(err)
return 1
bad_results = {}
for k in result:
if result[k] is not None:
bad_results[k] = result[k]
# no bad results --> nothing printed
for k in bad_result:
print(k, result[k])
return len(bad_results)
# accepts: path to output directory
# returns: combined json object, error message (None if no error)
# NOTE: the directory not existing is a fatal error
# processing a file that is invalid json AFTER the output directory
# has been validated produces a warning. the invalid json situation
# should be impossible, but also isn't enough to prevent assemble_output_dir
# from doing something reasonable.
def assemble_output_dir(output_dir):
_, err = validate_output(output_dir)
if err is not None:
return None, err
out = []
items = None
try:
items = os.listdir(output_dir)
except OSError:
return None, ("directory %s does not exist or is not readable" % output_dir)
for item in items:
obj = None
try:
with open(os.path.join(output_dir, item), "r") as fh:
try:
obj = json.load(fh)
except ValueError:
warnings.warn("file %s does not contain valid JSON" % item)
continue
except IOError:
warnings.warn("file %s somehow doesn't exist" % item)
continue
out.append(obj)
return out, None
# accepts: single json dictionary
# returns: error message (None if valid)
def validate_single_dut_json(obj):
with tempfile.NamedTemporaryFile(delete=True) as fh:
json.dump(obj, fh)
flush_sync(fh)
cmd = VALIDATE_CMD
cmd = cmd.replace('%%%PATH%%%', pipes.quote(fh.name))
returncode, out, err = shell_capture_all(cmd)
if returncode == 0:
return None
else:
return err
# accepts: json obj
# returns: hostname, error message (None if no error)
def get_hostname_from_dut_json(obj):
try:
common = obj["common"]
except KeyError:
return None, "dut has no common element"
if not isinstance(common, dict):
return None, ("common block must be dict not %s" % type(common))
try:
return common["hostname"], None
except KeyError:
return None, "common block has no hostname element"
# accepts: path to file
# returns: hostname mapping, error message
# hostname mapping has the following form
#
# {
# hostname -> new_dut_info_json
# }
#
# malformed entries are not included in the map
def load_hostname_map_file(filepath):
obj = None
try:
with open(filepath, "r") as fh:
try:
obj = json.load(fh)
except ValueError:
return None, ("file does not contain JSON %s" % filepath)
except IOError:
return None, ("cannot load hostname map from nonexistent file %s" % filepath)
out = {}
# return a singleton map if the toplevel entry is a dictionary
if isinstance(obj, dict):
err = validate_single_dut_json(obj)
if err is not None:
return None, err
hostname, err = get_hostname_from_dut_json(obj)
if err is not None:
return None, err
return {hostname: obj}, None
# validate all elements if the toplevel entry is a list
elif isinstance(obj, list):
for subobj in obj:
err = validate_single_dut_json(subobj)
if err is not None:
warnings.warn(err)
continue
hostname, err = get_hostname_from_dut_json(subobj)
if err is not None:
warnings.warn(err)
continue
# warn if we get a duplicate, but don't halt execution
if hostname in out:
warnings.warn(("duplicate hostname %s" % hostname))
out[hostname] = subobj
if len(out) == 0:
return out, "out cannot be empty"
return out, None
# accepts: path to directory
# returns: hostnaming mapping, error message
# hostname mapping has the following form
#
# {
# hostname -> new_dut_info_json
# }
#
# malformed entries are not included in the map
def load_hostname_map(dirpath):
items = None
try:
items = os.listdir(dirpath)
except OSError:
return None, ("cannot load from nonexistent directory %s" % dirpath)
if len(items) == 0:
return None, ("nothing in directory %s" % dirpath)
out = {}
for item in items:
hostname_map, err = load_hostname_map_file(os.path.join(dirpath, item))
if err is not None:
warnings.warn(err)
continue
for hostname in hostname_map:
if hostname in out:
warnings.warn("load_hostname_map: duplicate hostname %s" % hostname)
continue
out[hostname] = hostname_map[hostname]
return out, None
# free vars: %%%DIR%%%
QUICK_ADD_DUTS_CMD = r"""
skylab quick-add-duts %%%DIR%%%/*
"""
# accepts: list of hostnames, hostname_dirpath
# returns: error message (None if successful)
# NOTE: the quick-add-duts API is atomic
# emits: missing hostnames when there are missing hostnames
def do_quick_add_duts(hostnames, dirpath):
# validation
if isinstance(hostnames, TEXT):
return "hostnames cannot be %s" % type(hostnames)
# defensively prevent hostnames from being modified
hostnames = tuple(hostnames)
for hostname in hostnames:
if hostname.startswith("."):
return "hostname cannot start with '.' (%s)" % hostname
if not hostname:
return "hostname cannot be falsey (%s)" % hostname
hostnames_map, err = load_hostname_map(dirpath)
if err is not None:
return err
# check that every hostname is in the map before trying
missing_hostnames = set([])
for hostname in hostnames:
if hostname not in hostnames_map:
missing_hostnames.add(hostname)
if missing_hostnames:
for hostname in sorted(missing_hostnames):
print(("MISSING %s" % hostname))
return "%s missing hostnames" % len(missing_hostnames)
try:
# construct temporary directory of dut files.
tdir = tempfile.mkdtemp()
for hostname in hostnames:
newpath = os.path.join(tdir, hostname)
with open(newpath, "w") as fh:
json.dump(obj=hostnames_map[hostname], fp=fh)
# paranoia, check number of files.
num_files = len(os.listdir(tdir))
if num_files != len(hostnames):
return "internal error. hostnames: %s, files: %s, tdir: %s" % (len(hostnames), num_files, tdir)
# validate directory contents before proceeding
_, err = validate_output(tdir)
if err is not None:
return err
# execute the command, transfer file contents to skylab
# NOTE: this step is atomic
# note... skylab quick-add-duts will claim there's no deploy task ID
# when it is successful
# we need to parse the output to tell what happened
#
# right now, I use the presence of the magic strings 'nothing to commit'
# and 'missing deploy task ID in request entry' to diagnose what happened
# but this is fragile.
cmd = QUICK_ADD_DUTS_CMD
cmd = cmd.replace('%%%DIR%%%', pipes.quote(tdir))
returncode, out, err = shell_capture_all(cmd)
if " nothing to commit" in out:
warnings.warn("nothing to commit ... no change made to inventory")
return None
elif "missing deploy task ID in deploy request entry" in out:
print("SUCCESS!")
print(out)
return None
else:
return ("%s\n%s" % (out, err))
finally:
print(("path to tempdir: %s" % tdir))
# free vars: %%%MLIST%%%
ATEST_LOCK_RENAME_CMD = r"""
tdir="$(mktemp -d)"
test -d "$tdir" || exit 10
# produce the unmangled hostnames
cat -- %%%MLIST%%% | sed -e 's/-migrated-do-not-use$//' > "$tdir"/unmangled
cat -- "$tdir"/unmangled | sed -e 's/$/-migrated-do-not-use//' > "$tdir"/mangled
# just attempt to lock, discard results
# first process unmangled names
atest host mod --lock -r 'migration to skylab' --mlist "$tdir"/unmangled 2>&1
# then process mangled names
atest host mod --lock -r 'migration to skylab' --mlist "$tdir"/mangled 2>&1
# next attempt to rename only the unmangled names
atest host rename --for-migration --non-interactive --mlist "$tdir"/unmangled 2>&1
"""
# accepts: hostnames
# returns: error message (None if no error)
# emits: output
def atest_lock_rename(hostnames):
if isinstance(hostnames, TEXT):
return "hostnames cannot be text"
with tempfile.NamedTemporaryFile(delete=True) as fh:
for hostname in hostnames:
fh.write(hostname.strip())
fh.write("\n")
flush_sync(fh)
cmd = ATEST_LOCK_RENAME_CMD
cmd = cmd.replace('%%%MLIST%%%', pipes.quote(fh.name))
returncode, out, err = shell_capture_all(cmd)
print(out)
return None