Skip to content

Commit e52a326

Browse files
author
Alexandre Lissy
committed
Bug 1763188 - Add Snap support using TC builds
1 parent 8075648 commit e52a326

File tree

8 files changed

+357
-16
lines changed

8 files changed

+357
-16
lines changed

mozregression/cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,21 @@ def create_parser(defaults):
417417
help="Helps to write the configuration file.",
418418
)
419419

420+
parser.add_argument(
421+
"--allow-sudo",
422+
action="store_true",
423+
help=(
424+
"[Snap] Allow the use of sudo for Snap install/remove operations (otherwise,"
425+
" you will be prompted on each)"
426+
),
427+
)
428+
429+
parser.add_argument(
430+
"--disable-snap-connect",
431+
action="store_true",
432+
help="[Snap] Do not automatically perform 'snap connect'",
433+
)
434+
420435
parser.add_argument("--debug", "-d", action="store_true", help="Show the debug output.")
421436

422437
return parser
@@ -589,6 +604,11 @@ def validate(self):
589604
"x86",
590605
"x86_64",
591606
],
607+
"firefox-snap": [
608+
"aarch64", # will be morphed into arm64
609+
"arm", # will be morphed into armhf
610+
"x86_64", # will be morphed into amd64
611+
],
592612
}
593613

594614
user_defined_bits = options.bits is not None
@@ -618,6 +638,21 @@ def validate(self):
618638
f"`--arch` required for specified app ({options.app}). "
619639
f"Please specify one of {', '.join(arch_options[options.app])}."
620640
)
641+
elif options.app == "firefox-snap" and options.allow_sudo is False:
642+
self.logger.warning(
643+
"Bisection on Snap package without --allow-sudo, you will be prompted for"
644+
" credential on each 'snap' command."
645+
)
646+
elif options.allow_sudo is True and options.app != "firefox-snap":
647+
raise MozRegressionError(
648+
f"--allow-sudo specified for app ({options.app}), but only valid for "
649+
f"firefox-snap. Please verify your config."
650+
)
651+
elif options.disable_snap_connect is True and options.app != "firefox-snap":
652+
raise MozRegressionError(
653+
f"--disable-snap-conncet specified for app ({options.app}), but only valid for "
654+
f"firefox-snap. Please verify your config."
655+
)
621656

622657
fetch_config = create_config(
623658
options.app, mozinfo.os, options.bits, mozinfo.processor, options.arch

mozregression/fetch_configs.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,3 +812,59 @@ def build_regex(self):
812812
part = "mac"
813813
psuffix = "-asan" if "asan" in self.build_type else ""
814814
return r"jsshell-%s%s\.zip$" % (part, psuffix)
815+
816+
817+
TIMESTAMP_SNAP_UPSTREAM = to_utc_timestamp(datetime.datetime(2025, 4, 23, 16, 1, 56))
818+
819+
820+
class FirefoxSnapIntegrationConfigMixin(IntegrationConfigMixin):
821+
def tk_routes(self, push):
822+
for build_type in self.build_types:
823+
yield "gecko.v2.{}{}.revision.{}.firefox.snap-{}-{}".format(
824+
self.integration_branch,
825+
".shippable" if build_type == "shippable" else "",
826+
push.changeset,
827+
self.arch,
828+
"opt" if build_type == "shippable" else build_type,
829+
)
830+
self._inc_used_build()
831+
return
832+
833+
834+
class SnapCommonConfig(CommonConfig):
835+
def should_use_archive(self):
836+
# We only want to use TaskCluster builds
837+
return False
838+
839+
def build_regex(self):
840+
return r"(firefox_.*)\.snap"
841+
842+
843+
@REGISTRY.register("firefox-snap")
844+
class FirefoxSnapConfig(
845+
SnapCommonConfig, FirefoxSnapIntegrationConfigMixin, FirefoxNightlyConfigMixin
846+
):
847+
BUILD_TYPES = ("shippable", "opt", "debug")
848+
BUILD_TYPE_FALLBACKS = {
849+
"shippable": ("opt",),
850+
"opt": ("shippable",),
851+
}
852+
853+
def __init__(self, os, bits, processor, arch):
854+
super(FirefoxSnapConfig, self).__init__(os, bits, processor, arch)
855+
self.set_build_type("shippable")
856+
857+
def available_archs(self):
858+
return [
859+
"aarch64",
860+
"arm",
861+
"x86_64",
862+
]
863+
864+
def set_arch(self, arch):
865+
mapping = {
866+
"aarch64": "arm64",
867+
"arm": "armhf",
868+
"x86_64": "amd64",
869+
}
870+
self.arch = mapping.get(arch, "amd64")

mozregression/launchers.py

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44

55
from __future__ import absolute_import, print_function
66

7+
import hashlib
78
import json
89
import os
910
import stat
11+
import subprocess
1012
import sys
1113
import time
1214
import zipfile
1315
from abc import ABCMeta, abstractmethod
1416
from enum import Enum
17+
from shutil import move
1518
from subprocess import STDOUT, CalledProcessError, call, check_output
1619
from threading import Thread
1720

@@ -22,7 +25,7 @@
2225
from mozfile import remove
2326
from mozlog.structured import get_default_logger, get_proxy_logger
2427
from mozprofile import Profile, ThunderbirdProfile
25-
from mozrunner import Runner
28+
from mozrunner import GeckoRuntimeRunner, Runner
2629

2730
from mozregression.class_registry import ClassRegistry
2831
from mozregression.errors import LauncherError, LauncherNotRunnable
@@ -338,11 +341,15 @@ def get_app_info(self):
338341
REGISTRY = ClassRegistry("app_name")
339342

340343

341-
def create_launcher(buildinfo):
344+
def create_launcher(buildinfo, launcher_args=None):
342345
"""
343346
Create and returns an instance launcher for the given buildinfo.
344347
"""
345-
return REGISTRY.get(buildinfo.app_name)(buildinfo.build_file, task_id=buildinfo.task_id)
348+
return REGISTRY.get(buildinfo.app_name)(
349+
buildinfo.build_file,
350+
task_id=buildinfo.task_id,
351+
launcher_args=launcher_args,
352+
)
346353

347354

348355
class FirefoxRegressionProfile(Profile):
@@ -616,3 +623,171 @@ def cleanup(self):
616623
# always remove tempdir
617624
if self.tempdir is not None:
618625
remove(self.tempdir)
626+
627+
628+
# Should this be part of mozrunner ?
629+
class SnapRunner(GeckoRuntimeRunner):
630+
_allow_sudo = False
631+
_snap_pkg = None
632+
633+
def __init__(self, binary, cmdargs, allow_sudo=False, snap_pkg=None, **runner_args):
634+
self._allow_sudo = allow_sudo
635+
self._snap_pkg = snap_pkg
636+
super().__init__(binary, cmdargs, **runner_args)
637+
638+
@property
639+
def command(self):
640+
"""
641+
Rewrite the command for performing the actual execution with
642+
"snap run PKG", keeping everything else
643+
"""
644+
self._command = FirefoxSnapLauncher._get_snap_command(
645+
self._allow_sudo, "run", [self._snap_pkg] + super().command[1:]
646+
)
647+
return self._command
648+
649+
650+
@REGISTRY.register("firefox-snap")
651+
class FirefoxSnapLauncher(MozRunnerLauncher):
652+
profile_class = FirefoxRegressionProfile
653+
instanceKey = None
654+
snap_pkg = None
655+
binary = None
656+
allow_sudo = False
657+
disable_snap_connect = False
658+
runner = None
659+
660+
def __init__(self, dest, **kwargs):
661+
self.allow_sudo = kwargs["launcher_args"]["allow_sudo"]
662+
self.disable_snap_connect = kwargs["launcher_args"]["disable_snap_connect"]
663+
664+
if not self.allow_sudo:
665+
LOG.info(
666+
"Working with snap requires several 'sudo snap' commands. "
667+
"Not allowing the use of sudo will trigger many password confirmation dialog boxes."
668+
)
669+
else:
670+
LOG.info("Usage of sudo enabled, you should be prompted for your password once.")
671+
672+
super().__init__(dest)
673+
674+
def get_snap_command(self, action, extra):
675+
return FirefoxSnapLauncher._get_snap_command(self.allow_sudo, action, extra)
676+
677+
def _get_snap_command(allow_sudo, action, extra):
678+
if action not in ("connect", "install", "run", "refresh", "remove"):
679+
raise LauncherError(f"Snap operation {action} unsupported")
680+
681+
cmd = []
682+
if allow_sudo and action in ("connect", "install", "refresh", "remove"):
683+
cmd += ["sudo"]
684+
685+
cmd += ["snap", action]
686+
cmd += extra
687+
688+
return cmd
689+
690+
def _install(self, dest):
691+
# From https://snapcraft.io/docs/parallel-installs#heading--naming
692+
# - The instance key needs to be manually appended to the snap name,
693+
# and takes the following format: <snap>_<instance-key>
694+
# - The instance key must match the following regular expression:
695+
# ^[a-z0-9]{1,10}$.
696+
self.instanceKey = hashlib.sha1(os.path.basename(dest).encode("utf8")).hexdigest()[0:9]
697+
self.snap_pkg = "firefox_{}".format(self.instanceKey)
698+
self.binary = "/snap/{}/current/usr/lib/firefox/firefox".format(self.snap_pkg)
699+
700+
subprocess.run(
701+
self.get_snap_command(
702+
"install", ["--name", self.snap_pkg, "--dangerous", "{}".format(dest)]
703+
),
704+
check=True,
705+
)
706+
self._fix_connections()
707+
708+
self.binarydir = os.path.dirname(self.binary)
709+
self.appdir = os.path.normpath(os.path.join(self.binarydir, "..", ".."))
710+
711+
LOG.debug(f"snap package: {self.snap_pkg} {self.binary}")
712+
713+
# On Snap updates are already disabled
714+
715+
def _fix_connections(self):
716+
if self.disable_snap_connect:
717+
return
718+
719+
existing = {}
720+
for line in subprocess.getoutput("snap connections {}".format(self.snap_pkg)).splitlines()[
721+
1:
722+
]:
723+
interface, plug, slot, _ = line.split()
724+
existing[plug] = slot
725+
726+
for line in subprocess.getoutput("snap connections firefox").splitlines()[1:]:
727+
interface, plug, slot, _ = line.split()
728+
ex_plug = plug.replace("firefox:", "{}:".format(self.snap_pkg))
729+
ex_slot = slot.replace("firefox:", "{}:".format(self.snap_pkg))
730+
if existing[ex_plug] == "-":
731+
if ex_plug != "-" and ex_slot != "-":
732+
cmd = self.get_snap_command(
733+
"connect", ["{}".format(ex_plug), "{}".format(ex_slot)]
734+
)
735+
LOG.debug(f"snap connect: {cmd}")
736+
subprocess.run(cmd, check=True)
737+
738+
def _create_profile(self, profile=None, addons=(), preferences=None):
739+
"""
740+
Let's create a profile as usual, but rewrite its path to be in Snap's
741+
dir because it looks like MozProfile class will consider a profile=xxx
742+
to be a pre-existing one
743+
"""
744+
real_profile = super()._create_profile(profile, addons, preferences)
745+
snap_profile_dir = os.path.abspath(
746+
os.path.expanduser("~/snap/{}/common/.mozilla/firefox/".format(self.snap_pkg))
747+
)
748+
if not os.path.exists(snap_profile_dir):
749+
os.makedirs(snap_profile_dir)
750+
profile_dir_name = os.path.basename(real_profile.profile)
751+
snap_profile = os.path.join(snap_profile_dir, profile_dir_name)
752+
move(real_profile.profile, snap_profile_dir)
753+
real_profile.profile = snap_profile
754+
return real_profile
755+
756+
def _start(
757+
self,
758+
profile=None,
759+
addons=(),
760+
cmdargs=(),
761+
preferences=None,
762+
adb_profile_dir=None,
763+
allow_sudo=False,
764+
disable_snap_connect=False,
765+
):
766+
profile = self._create_profile(profile=profile, addons=addons, preferences=preferences)
767+
768+
LOG.info("Launching %s [%s]" % (self.binary, self.allow_sudo))
769+
self.runner = SnapRunner(
770+
binary=self.binary,
771+
cmdargs=cmdargs,
772+
profile=profile,
773+
allow_sudo=self.allow_sudo,
774+
snap_pkg=self.snap_pkg,
775+
)
776+
self.runner.start()
777+
778+
def _wait(self):
779+
self.runner.wait()
780+
781+
def _stop(self):
782+
self.runner.stop()
783+
# release the runner since it holds a profile reference
784+
del self.runner
785+
786+
def cleanup(self):
787+
try:
788+
Launcher.cleanup(self)
789+
finally:
790+
subprocess.run(self.get_snap_command("remove", [self.snap_pkg]))
791+
792+
def get_app_info(self):
793+
return safe_get_version(binary=self.binary)

mozregression/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ def test_runner(self):
8989
cmdargs=self.options.cmdargs,
9090
preferences=self.options.preferences,
9191
adb_profile_dir=self.options.adb_profile_dir,
92+
allow_sudo=self.options.allow_sudo,
93+
disable_snap_connect=self.options.disable_snap_connect,
9294
)
9395
)
9496
else:

mozregression/test_runner.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
LOG = get_proxy_logger("Test Runner")
2121

2222

23-
def create_launcher(build_info):
23+
def create_launcher(build_info, launcher_args):
2424
"""
2525
Create and returns a :class:`mozregression.launchers.Launcher`.
2626
"""
@@ -36,7 +36,7 @@ def create_launcher(build_info):
3636
)
3737
LOG.info("Running %s build %s" % (build_info.repo_name, desc))
3838

39-
return mozlauncher(build_info)
39+
return mozlauncher(build_info, launcher_args)
4040

4141

4242
class TestRunner(metaclass=ABCMeta):
@@ -82,6 +82,19 @@ def index_to_try_after_skip(self, build_range):
8282
"""
8383
return build_range.mid_point()
8484

85+
def maybe_snap(self):
86+
"""
87+
Checking if the launcher migth contain Snap specific bits and return
88+
them if it's the case, defaulting to False else.
89+
"""
90+
if hasattr(self, "launcher_kwargs") and "allow_sudo" in self.launcher_kwargs.keys():
91+
return (
92+
self.launcher_kwargs["allow_sudo"],
93+
self.launcher_kwargs["disable_snap_connect"],
94+
)
95+
else:
96+
return (False, False)
97+
8598

8699
class ManualTestRunner(TestRunner):
87100
"""
@@ -117,7 +130,7 @@ def get_verdict(self, build_info, allow_back):
117130
return verdict[0]
118131

119132
def evaluate(self, build_info, allow_back=False):
120-
with create_launcher(build_info) as launcher:
133+
with create_launcher(build_info, self.launcher_kwargs) as launcher:
121134
launcher.start(**self.launcher_kwargs)
122135
build_info.update_from_app_info(launcher.get_app_info())
123136
verdict = self.get_verdict(build_info, allow_back)
@@ -131,7 +144,7 @@ def evaluate(self, build_info, allow_back=False):
131144
return verdict
132145

133146
def run_once(self, build_info):
134-
with create_launcher(build_info) as launcher:
147+
with create_launcher(build_info, self.launcher_kwargs) as launcher:
135148
launcher.start(**self.launcher_kwargs)
136149
build_info.update_from_app_info(launcher.get_app_info())
137150
return launcher.wait()

0 commit comments

Comments
 (0)