| #!/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 |