blob: 5522d8bc62dcf891bd8f77c18448b37f7d27a831 [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2024 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import ipaddress
import json
import subprocess
import sys
from dataclasses import dataclass
# Sample wrapper class, good enough to support the strict.py example.
@dataclass
class Target:
address: str
state: str | None
def format_target_address(addr):
# Expects a dictionary representing an address returned by `ffx target list --machine json`.
# Expected schema:
# - type: "Ip" or "Usb" (case insensitive).
# - ip: The IP address string (required for type="Ip").
# - ssh_port: The SSH port number (optional/fallback for type="Ip").
# - cid: The USB CID number (required for type="Usb").
if addr.get("type", "").lower() == "usb":
cid = addr.get("cid")
if cid is None:
raise ValueError("Address dict missing 'cid' field for USB target")
return f"usb:cid:{cid}"
port = addr.get("ssh_port")
if port == 0 or port is None:
port = 22
ip = addr.get("ip")
if not ip:
raise ValueError("Address dict missing 'ip' field")
if ":" in ip and not ip.startswith("["):
return f"[{ip}]:{port}"
return f"{ip}:{port}"
def is_already_resolved(target_name):
"""Checks if the target_name is already a resolved address.
Returns True if it is an IPv4, IPv6 (with optional brackets, port, or scope ID),
or starts with 'usb:'. Otherwise returns False.
"""
if target_name.startswith("usb:"):
return True
# Strip optional brackets and port
ip = target_name
if ip.startswith("["):
end_bracket = ip.find("]")
if end_bracket != -1:
ip = ip[1:end_bracket]
else:
if ":" in ip:
parts = ip.rsplit(":", 1)
if parts[0].count(":") == 0:
ip = parts[0]
if "%" in ip:
ip = ip.split("%", 1)[0]
try:
ipaddress.ip_address(ip)
return True
except ValueError:
return False
class FfxRunner:
def __init__(self, log_file, ssh_key, target=None, verbose=False):
"""Initializes FfxRunner.
Args:
log_file: Path to the log file for ffx strict operations.
ssh_key: Path to the private SSH key for target connection.
target: Optional target name (nodename) or resolved socket address
(IP:port or usb:cid:<num>). If None, the default target is
discovered automatically.
verbose: If True, prints verbose debug logs.
"""
self.log_file = log_file
self.ssh_key = ssh_key
self.verbose = verbose
self.target = None
if target:
self.resolve_target(target)
else:
# If no target is explicitly specified, discover the default target.
self.discover_target()
def debug(self, message):
if self.verbose:
print(message, file=sys.stderr)
def resolve_target(self, target_name):
# Resolves the target name to a formatted IP:PORT or usb:cid:<num> address
# expected by ffx --strict.
if is_already_resolved(target_name):
self.target = Target(target_name, None)
return self.target
# Query ffx target list with target_name to resolve it to an IP address/port.
target_list = self.target_list(None, query=target_name)
if not target_list:
raise Exception(
f"No targets found in target list when matching '{target_name}'"
)
# The query returns only targets matching target_name, so we take the first match.
target_dict = target_list[0]
address = format_target_address(target_dict["addresses"][0])
self.target = Target(address, target_dict.get("target_state"))
return self.target
def run_raw(self, opts, args):
cmd = ["ffx"]
cmdline = cmd + opts + args
self.debug(" ".join(cmdline))
try:
return subprocess.run(
cmdline, check=True, capture_output=True
).stdout
except subprocess.CalledProcessError as e:
print(e.stderr.decode("utf-8"))
raise
def run(self, target, opts, args):
target_address = (
target.address if isinstance(target, Target) else target
)
if target_address:
opts.extend(["--target", target_address])
out = self.run_raw(opts + ["--machine", "json"], args)
return json.loads(out)
def required_configs(self):
return [f"ssh.priv={self.ssh_key}"]
def run_strict(self, target, args, extra_configs=None):
if extra_configs is None:
extra_configs = []
target_address = (
target.address if isinstance(target, Target) else target
)
cfgs = ",".join(self.required_configs() + extra_configs)
res = self.run(
target_address,
["--strict", "--config", cfgs, "-o", self.log_file],
args,
)
self.debug(res)
if "unexpected_error" in res:
raise Exception(res["unexpected_error"])
return res
def discover_target(self):
targets = self.run(None, [], ["target", "list"])
if len(targets) != 1:
raise Exception("cannot determine default target")
address = format_target_address(targets[0]["addresses"][0])
self.target = Target(address, targets[0].get("target_state"))
def target_echo(self, msg):
"""Runs 'ffx target echo' with a message and returns the response."""
res = self.run_strict(self.target, ["target", "echo", msg])
return res["message"]
def target_list(self, emu_instance_dir, query=None):
"""Lists available targets, optionally filtering by query."""
if emu_instance_dir:
extra_configs = [f"emu.instance_dir={emu_instance_dir}"]
else:
extra_configs = ["emu.instance_dir="]
args = ["target", "list"]
if query:
args.append(query)
res = self.run_strict(self.target, args, extra_configs)
return res
def target_show(self):
"""Runs 'ffx target show' and returns the target details."""
res = self.run_strict(self.target, ["target", "show"])
return res
def component_list(self):
"""Runs 'ffx component list' and returns the component list."""
res = self.run_strict(self.target, ["component", "list"])
return res