Skip to content

Commit

Permalink
Merge pull request #49 from LaiderLai/ssh
Browse files Browse the repository at this point in the history
Support an IoT deployment method with a USB stick that flashed with an EFI based ISO
  • Loading branch information
kiya956 authored Dec 28, 2024
2 parents f0d98fd + f0d67e8 commit cf205d3
Show file tree
Hide file tree
Showing 10 changed files with 298 additions and 10 deletions.
12 changes: 10 additions & 2 deletions README
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ tplan yaml syntax:
project_name: <string> #your project name
username: <string> #target device user name
password: <string> #target device password
# connection setup (Either serial console or ssh)
# serial console port setup
# port: the device path of the serial port, such as /dev/ttyUSB0
# baud_rate: baud rate of the serial port [115200, 9600, 921600]
serial_console:
port: <string>
baud_rate: <int>
# ssh connection setup
# ip: the connection ip address
# port: the connection port
ssh:
ip: <string>
port: <int>
network: <string> #network interface name such as eth0
extra-recepients: <list of string> #a list of mail address
hostname: <string> #the host name show in your console *optional*
Expand All @@ -24,7 +31,8 @@ tplan yaml syntax:
- <string 1>
- <string 2>

- eof_commands: <list of dict> #send command to console, the keyword is "console_commands" in testflinger
- eof_commands: <list of dict> #send command to target system
# for console, the keyword is "console_commands" in testflinger
- cmd: <string>
expected: <string> #your expectped string in result *optional*
- cmd: <string>
Expand All @@ -50,7 +58,7 @@ tplan yaml syntax:
timeout: <int>

- deploy:
# utility: choose from [utp_com, uuu, seed_override, seed_override_lk]
# utility: choose from [utp_com, uuu, seed_override, seed_override_lk, iso]
# method: choose from [cloud-init, system-user]
# timeout: how long the tool would waiting before timeout in second *optional*
# (if seed_override or seed_override_lk)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"schedule",
"wrapt_timeout_decorator",
"whl",
"paramiko",
]

dynamic = ["version"]
Expand Down
26 changes: 21 additions & 5 deletions sanity/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from sanity.agent.scheduler import Scheduler
from sanity.agent.data import DevData
from sanity.agent.err import FAILED
from sanity.agent.ssh import SSHConnection
from sanity.launcher.parser import LauncherParser


Expand All @@ -25,13 +26,22 @@ def start_agent(cfg):
DevData.project = cfg_data.get("project_name")
DevData.device_uname = cfg_data.get("username")
DevData.device_pwd = cfg_data.get("password")
con = Console(
DevData.device_uname,
cfg_data["serial_console"]["port"],
cfg_data["serial_console"]["baud_rate"],
)
DevData.IF = cfg_data["network"]

if cfg_data.get("ssh"):
con = SSHConnection(
cfg_data["ssh"]["ip"],
cfg_data["ssh"]["port"],
cfg_data.get("username"),
cfg_data.get("password"),
)
else:
con = Console(
DevData.device_uname,
cfg_data["serial_console"]["port"],
cfg_data["serial_console"]["baud_rate"],
)

if cfg_data.get("recipients"):
Mail.recipients.extend(cfg_data.get("recipients"))

Expand All @@ -55,3 +65,9 @@ def start_agent(cfg):
f"{DevData.project} device disconnected "
"or multiple access on port?",
)
except TimeoutError as e:
print(f"Timeout with the device SSH connection error code {e}")
Mail.send_mail(
FAILED,
f"{DevData.project} timeout with the device SSH connection",
)
4 changes: 4 additions & 0 deletions sanity/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from sanity.agent.deploy import login, boot_login, deploy
from sanity.agent.cmd import syscmd
from sanity.agent.err import FAILED
from sanity.agent.ssh import SSHConnection


def notify(status):
Expand Down Expand Up @@ -96,6 +97,9 @@ def start(plan, con, sched=None):
notify(status)
elif "eof_commands" in stage.keys():
print(gen_head_string("custom command start"))
if isinstance(con, SSHConnection):
if not con.isconnected():
con.connection()
for cmd in stage["eof_commands"]:
result = con.write_con(cmd.get("cmd"))
expected = cmd.get("expected", None)
Expand Down
7 changes: 6 additions & 1 deletion sanity/agent/checkbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
from sanity.agent.err import FAILED, SUCCESS
from sanity.agent.net import get_ip, check_net_connection
from sanity.agent.data import DevData
from sanity.agent.ssh import SSHConnection


# pylint: disable=R1705,R0801
def run_checkbox(con, runner_cfg, secure_id, desc):
"""run checkbox and submit report to C3"""

addr = get_ip(con)
if isinstance(con, SSHConnection):
addr = con.getname()
else:
addr = get_ip(con)

if addr == FAILED:
return {
"code": FAILED,
Expand Down
4 changes: 3 additions & 1 deletion sanity/agent/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sanity.agent.style import columns
from sanity.agent.err import FAILED, SUCCESS
from sanity.agent.data import DevData
from sanity.agent.iso import DeploymentISO


INSTALL_MODE = "install"
Expand Down Expand Up @@ -369,7 +370,8 @@ def deploy(

if method == "seed_override_nocheck":
return {"code": SUCCESS}

case "iso":
return DeploymentISO().result(con)
case _:
return {"code": FAILED}

Expand Down
83 changes: 83 additions & 0 deletions sanity/agent/iso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""This module provides auto-ISO deployment checking"""

import os
import subprocess
import time
from sanity.agent.err import FAILED, SUCCESS


class DeploymentISO:
"""checking auto-deployment ISO status"""

instlog = None
timeout = None

def __init__(self, instlog="installer-logs.tar.xz", timeout=1800):
self.instlog = instlog
self.timeout = timeout

def infile(self, string, filename):
"""check if the string in the file or not"""
with open(filename, encoding="utf-8") as file:
for line in file:
if string in line:
return True
return False

def chklog(self, logdir):
"""check log content"""
crashdir = f"{logdir}/var/crash"
if os.path.exists(crashdir) and os.listdir(crashdir):
return {
"code": FAILED,
"mesg": f"Please check the crash log under {crashdir}",
}

curtinlog = f"{logdir}/var/log/installer/curtin-install.log"
if not self.infile("SUCCESS: curtin command extract", curtinlog):
return {
"code": FAILED,
"mesg": f"Please check the {curtinlog}"
f"to know why installer is NOT finished with dd",
}
print(
"Found the installation process is finished"
" correctly from the log"
)
return {"code": SUCCESS}

def result(self, con):
"""identify auto-installer ISO deployment result via connection"""
# Checking installation status by connection
con.connection()

homedir = os.path.expanduser("~")
savedlog = f"{homedir}/{self.instlog}"

# Check the installation completed log is exist
waiting = time.time() + self.timeout
while True:
try:
con.download(f"/{self.instlog}", savedlog)
except FileNotFoundError as e:
print(e)
if time.time() > waiting:
raise TimeoutError(
"TimeoutError: the installation is not complete"
f"because there is no /{self.instlog}"
) from e
time.sleep(5)
continue
break
con.close()
print(f"The {savedlog} is saved")

# Extract and check the log
extractdir = f"{homedir}/a-s-tmp"
subprocess.run(["mkdir", "-p", extractdir], check=False)
subprocess.run(
["tar", "-Jxf", savedlog, "-C", extractdir],
check=False,
)

return self.chklog(extractdir)
124 changes: 124 additions & 0 deletions sanity/agent/ssh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""This module provides SSH relvent methods"""

import logging
import time
from dataclasses import dataclass
import paramiko


@dataclass
class SSHInfo:
"""the ssh connection information"""

name: str
port: int
uname: str
passwd: str
timeout: int


class SSHConnection:
"""handle send and receive through SSH to target"""

info = None
client = None
sftp = None
stdout = None
stderr = None

# pylint: disable=R0913,R0917
def __init__(self, name, port, uname, passwd, timeout=1800):
self.info = SSHInfo(name, port, uname, passwd, timeout)

def __del__(self):
self.close()

def getname(self):
"""return SSH target name"""
return self.info.name

def isconnected(self):
"""check if the connection is created"""
return self.client is not None and self.sftp is not None

def connection(self):
"""initial the connection"""
if self.client:
self.close()

self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

waiting = time.time() + self.info.timeout
while True:
try:
self.client.connect(
self.info.name,
self.info.port,
self.info.uname,
self.info.passwd,
)
except (
paramiko.ssh_exception.NoValidConnectionsError,
paramiko.ssh_exception.AuthenticationException,
paramiko.ssh_exception.SSHException,
) as e:
print(e)
if time.time() > waiting:
raise TimeoutError(
f"TimeoutError: the {self.info.name}:{self.info.port}"
" connection is failed"
) from e
time.sleep(5)
continue

self.sftp = self.client.open_sftp()
break

def close(self):
"""close connection"""
if self.client:
self.client.close()
self.client = None
self.sftp = None

def write_con(self, cmd):
"""send command and return result"""
if self.client:
sshcmd = f"echo {self.info.passwd} | sudo -S {cmd}"
_stdin, _stdout, _stderr = self.client.exec_command(sshcmd)
self.stdout = _stdout.read().decode("utf-8")
self.stderr = _stderr.read().decode("utf-8")
return self.read_con()

def read_con(self):
"""return the latest result from write_con()"""
if self.stderr:
logging.info(self.stderr)
return self.stderr
if self.stdout:
logging.info(self.stdout)
return self.stdout
return None

def download(self, remote, local):
"""download from remote to local"""
if self.sftp:
self.sftp.get(remote, local)

def upload(self, local, remote):
"""upload from local to remote"""
if self.sftp:
self.sftp.put(local, remote)

def log(self, name="ssh.log"):
"""store result to a file"""
root = logging.getLogger()
if root.handlers:
for handler in root.handlers:
root.removeHandler(handler)

logformat = "%(asctime)s: %(message)s"
logging.basicConfig(
level=logging.INFO, filename=name, filemode="a", format=logformat
)
10 changes: 9 additions & 1 deletion sanity/launcher/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
},
"required": ["port", "baud_rate"],
},
"ssh": {
"type": "object",
"properties": {
"ip": {"type": "string"},
"port": {"type": "integer"},
},
"required": ["ip", "port"],
},
"network": {"type": "string"},
"recipients": {
"type": "array",
Expand All @@ -38,7 +46,6 @@
"project_name",
"username",
"password",
"serial_console",
"network",
],
},
Expand Down Expand Up @@ -67,6 +74,7 @@
"seed_override",
"seed_override_lk",
"seed_override_nocheck",
"iso",
],
},
"method": {"$ref": "#/$defs/method"},
Expand Down
Loading

0 comments on commit cf205d3

Please sign in to comment.