Skip to content

Commit

Permalink
always resume automated sessions via remote
Browse files Browse the repository at this point in the history
  • Loading branch information
kissiel committed Dec 1, 2023
1 parent 1ed554f commit 5a54d7f
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 81 deletions.
121 changes: 73 additions & 48 deletions checkbox-ng/checkbox_ng/launcher/agent.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# This file is part of Checkbox.
#
# Copyright 2017-2019 Canonical Ltd.
# Copyright 2017-2023 Canonical Ltd.
# Written by:
# Maciej Kisielewski <[email protected]>
#
# Checkbox is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 3,
Expand All @@ -20,11 +19,15 @@
functionality.
"""
import gettext
import json
import logging
import os
import socket
import sys
from checkbox_ng import app_context
from plainbox.impl.config import Configuration
from plainbox.impl.secure.sudo_broker import is_passwordless_sudo
from plainbox.impl.session.assistant import ResumeCandidate
from plainbox.impl.session.remote_assistant import RemoteSessionAssistant
from plainbox.impl.session.restart import RemoteDebRestartStrategy
from plainbox.impl.session.restart import RemoteSnappyRestartStrategy
Expand All @@ -36,7 +39,6 @@


class SessionAssistantAgent(rpyc.Service):

session_assistant = None
controlling_controller_conn = None
controller_blaster = None
Expand All @@ -58,10 +60,14 @@ def exposed_register_controller_blaster(self, callable):
def on_connect(self, conn):
try:
if SessionAssistantAgent.controller_blaster:
msg = 'Forcefully disconnected by new controller from {}:{}'.format(
conn._config['endpoints'][1][0], conn._config['endpoints'][1][1])
msg = "Forcefully disconnected by new controller from {}:{}".format(
conn._config["endpoints"][1][0],
conn._config["endpoints"][1][1],
)
SessionAssistantAgent.controller_blaster(msg)
old_controller = SessionAssistantAgent.controlling_controller_conn
old_controller = (
SessionAssistantAgent.controlling_controller_conn
)
if old_controller is not None:
old_controller.close()
SessionAssistantAgent.controller_blaster = None
Expand All @@ -82,7 +88,7 @@ def on_disconnect(self, conn):
self.controlling_controller_conn = None


class RemoteAgent():
class RemoteAgent:
"""
Run checkbox instance as a agent
Expand All @@ -91,76 +97,95 @@ class RemoteAgent():
part should be run on system-under-test.
"""

name = 'agent'
name = "agent"

def invoked(self, ctx):
if os.geteuid():
raise SystemExit(_("Checkbox agent must be run by root!"))
if not is_passwordless_sudo():
raise SystemExit(
_("System is not configured to run sudo without a password!"))
_("System is not configured to run sudo without a password!")
)
if ctx.args.resume:
msg = (
"--resume is deprecated and will be removed soon. "
"Automated sessions are now always resumed. "
"Manual sessions can be resumed from the welcome screen."
)
_logger.warning(msg)

agent_port = ctx.args.port

# Check if able to connect to the agent port as indicator of there
# already being a agent running
def agent_port_open():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.5)
result = sock.connect_ex(('127.0.0.1', agent_port))
result = sock.connect_ex(("127.0.0.1", agent_port))
sock.close()
return result

if agent_port_open() == 0:
raise SystemExit(_("Found port {} is open. Is Checkbox agent"
" already running?").format(agent_port))
raise SystemExit(
_(
"Found port {} is open. Is Checkbox agent"
" already running?"
).format(agent_port)
)

SessionAssistantAgent.session_assistant = RemoteSessionAssistant(
lambda s: [sys.argv[0] + 'agent'])
snap_data = os.getenv('SNAP_DATA')
snap_rev = os.getenv('SNAP_REVISION')
remote_restart_strategy_debug = os.getenv('REMOTE_RESTART_DEBUG')
if (snap_data and snap_rev) or ctx.args.resume:
if remote_restart_strategy_debug:
strategy = RemoteSnappyRestartStrategy(debug=True)
else:
strategy = RemoteSnappyRestartStrategy()
if os.path.exists(strategy.session_resume_filename):
with open(strategy.session_resume_filename, 'rt') as f:
session_id = f.readline()
SessionAssistantAgent.session_assistant.resume_by_id(
session_id)
elif ctx.args.resume:
# XXX: explicitly passing None to not have to bump Remote API
# TODO: remove on the next Remote API bump
SessionAssistantAgent.session_assistant.resume_by_id(None)
else:
_logger.info("RemoteDebRestartStrategy")
if remote_restart_strategy_debug:
strategy = RemoteDebRestartStrategy(debug=True)
else:
strategy = RemoteDebRestartStrategy()
if os.path.exists(strategy.session_resume_filename):
with open(strategy.session_resume_filename, 'rt') as f:
session_id = f.readline()
_logger.info(
"RemoteDebRestartStrategy resume_by_id %r", session_id)
lambda s: [sys.argv[0] + "agent"]
)

# the agent is meant to be run only as a service,
# and we always resume if the session was automated,
# so we don't need to encode check whether we should resume

sessions = list(ctx.sa.get_resumable_sessions())
if sessions:
# the sessions are ordered by time, so the first one is the most
# recent one
if is_the_session_noninteractive(sessions[0]):
SessionAssistantAgent.session_assistant.resume_by_id(
session_id)
sessions[0].id
)

self._server = ThreadedServer(
SessionAssistantAgent,
port=agent_port,
protocol_config={
"allow_all_attrs": True,
"allow_setattr": True,
"sync_request_timeout": 1,
"propagate_SystemExit_locally": True
"propagate_SystemExit_locally": True,
},
)
SessionAssistantAgent.session_assistant.terminate_cb = (
self._server.close)
self._server.close
)
self._server.start()

def register_arguments(self, parser):
parser.add_argument('--resume', action='store_true', help=_(
"resume last session"))
parser.add_argument('--port', type=int, default=18871, help=_(
"port to listen on"))
parser.add_argument(
"--resume", action="store_true", help=_("resume last session")
)
parser.add_argument(
"--port", type=int, default=18871, help=_("port to listen on")
)


def is_the_session_noninteractive(
resumable_session: "ResumeCandidate",
) -> bool:
"""
Check if given session is non-interactive.
To determine that we need to take the original launcher that had been used
when the session was started, recreate it as a proper Launcher object, and
check if it's in fact non-interactive.
"""
# app blob is a bytes string with a utf-8 encoded json
# let's decode it and parse it as json
app_blob = json.loads(resumable_session.metadata.app_blob.decode("utf-8"))
launcher = Configuration.from_text(app_blob["launcher"], "resumed session")
return launcher.sections["ui"].get("type") == "silent"
9 changes: 2 additions & 7 deletions checkbox-ng/checkbox_ng/launcher/checkbox_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def main():
"slave": "run-agent",
"service": "run-agent",
"master": "control",
"remote": "control"
"remote": "control",
}

known_cmds = list(commands.keys())
Expand Down Expand Up @@ -141,12 +141,7 @@ def main():
subcmd = commands[args.subcommand]()
subcmd.register_arguments(subcmd_parser)
sub_args = subcmd_parser.parse_args(sys.argv[subcmd_index + 1 :])
sa = SessionAssistant(
"com.canonical:checkbox-cli",
"0.99",
"0.99",
["restartable"],
)
sa = SessionAssistant()
ctx = Context(sub_args, sa)
try:
socket.getaddrinfo("localhost", 443) # 443 for HTTPS
Expand Down
25 changes: 25 additions & 0 deletions checkbox-ng/checkbox_ng/launcher/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@
# You should have received a copy of the GNU General Public License
# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.

import json
from unittest import TestCase, mock

from checkbox_ng.launcher.agent import is_the_session_noninteractive
from checkbox_ng.launcher.agent import RemoteAgent
from plainbox.impl.session.assistant import ResumeCandidate
from plainbox.impl.session.state import SessionMetaData


class AgentTests(TestCase):
Expand Down Expand Up @@ -61,3 +65,24 @@ def test_invoked_ok(
server = threaded_server_mock.return_value
# the server was started
self.assertTrue(server.start.called)


class IsTheSessionNonInteractiveTests(TestCase):
def test_a_non_interactive_one(self):
a_non_interactive_launcher = """
[ui]
type = silent
"""
app_blob = json.dumps({"launcher": a_non_interactive_launcher})
metadata = SessionMetaData(app_blob=app_blob.encode("utf-8"))

candidate = ResumeCandidate("an_id", metadata)
self.assertTrue(is_the_session_noninteractive(candidate))

def test_an_interactive(self):
a_non_interactive_launcher = "" # the defautl one is interactive
app_blob = json.dumps({"launcher": a_non_interactive_launcher})
metadata = SessionMetaData(app_blob=app_blob.encode("utf-8"))

candidate = ResumeCandidate("an_id", metadata)
self.assertFalse(is_the_session_noninteractive(candidate))
9 changes: 8 additions & 1 deletion checkbox-ng/plainbox/impl/session/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
import time
from tempfile import SpooledTemporaryFile


from checkbox_ng.app_context import application_name

from plainbox.abc import IJobResult
from plainbox.abc import IJobRunnerUI
from plainbox.abc import ISessionStateTransport
Expand Down Expand Up @@ -123,7 +126,11 @@ class SessionAssistant:
# TODO: create a flowchart of possible states

def __init__(
self, app_id, app_version=None, api_version="0.99", api_flags=()
self,
app_id=application_name(),
app_version=None,
api_version="0.99",
api_flags=(),
):
"""
Initialize a new session assistant.
Expand Down
18 changes: 13 additions & 5 deletions checkbox-ng/plainbox/impl/session/remote_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def __init__(self, cmd_callback):
def _reset_sa(self):
_logger.info("Resetting RSA")
self._state = Idle
self._sa = SessionAssistant("service", api_flags={SA_RESTARTABLE})
self._sa = SessionAssistant()
self._be = None
self._session_id = ""
self._jobs_count = 0
Expand Down Expand Up @@ -292,7 +292,8 @@ def start_session(self, configuration):
configuration["launcher"], "Remote launcher"
)
self._launcher.update_from_another(
launcher_from_controller, "Remote launcher")
launcher_from_controller, "Remote launcher"
)
session_title = (
self._launcher.get_value("launcher", "session_title")
or session_title
Expand Down Expand Up @@ -326,8 +327,6 @@ def start_session(self, configuration):
}
).encode("UTF-8")
self._sa.update_app_blob(new_blob)
self._sa.configure_application_restart(self._cmd_callback)

self._session_id = self._sa.get_session_id()
tps = self._sa.get_test_plans()
filtered_tps = set()
Expand Down Expand Up @@ -702,7 +701,8 @@ def resume_by_id(self, session_id=None):
app_blob["launcher"], "Remote launcher"
)
self._launcher.update_from_another(
launcher_from_controller, "Remote launcher")
launcher_from_controller, "Remote launcher"
)
self._sa.use_alternate_configuration(self._launcher)

self._normal_user = app_blob.get(
Expand Down Expand Up @@ -740,6 +740,14 @@ def resume_by_id(self, session_id=None):
result_dict["outcome"] = IJobResult.OUTCOME_PASS
except json.JSONDecodeError:
pass
else:
the_job = self._sa.get_job(self._last_job)
if the_job.plugin == "shell":
if "noreturn" in the_job.get_flag_set():
result_dict["outcome"] = IJobResult.OUTCOME_PASS
else:
result_dict["outcome"] = IJobResult.OUTCOME_CRASH

result = MemoryJobResult(result_dict)
if self._last_job:
try:
Expand Down
Loading

0 comments on commit 5a54d7f

Please sign in to comment.